#!/bin/bash # Sync-Test: lokaler Server plus headless Shell, Änderungen in beide Richtungen: dev/tests/sync.sh DIR=$(cd "$(dirname "$0")/.." && pwd) ROOT=$(cd "$DIR/.." && pwd) E="$DIR/eval.sh" OUT=/tmp/desktop-app-nested/out.txt PORT=8041 URL="http://localhost:$PORT" SERVER_DATA=/tmp/desktop-app-sync-server AUTH="Authorization: Bearer test" FAILED=0 PRE='const ext = Main.extensionManager.lookup("desktop@marha.de").stateObj; const notes = ext._root.get_child_at_index(0); const todos = ext._root.get_child_at_index(1).get_child_at_index(1); const store = ext._store; const read = p => new TextDecoder().decode(GLib.file_get_contents(p)[1]); const exists = p => GLib.file_test(p, GLib.FileTest.EXISTS); const state = () => JSON.parse(read(store.dir + "/sync-state.json")); const St = imports.gi.St; const labels = a => { const out = []; const walk = n => { if (n instanceof St.Label && n.text) out.push(n.text); n.get_children().forEach(walk); }; walk(a); return out; }; ' val() { "$E" "$PRE GLib.file_set_contents('$OUT', String($1)); 'w'" | grep -v "^OK '\"w\"')$" cat "$OUT" } api() { curl -s -H "$AUTH" -H "Content-Type: application/json" "$@"; } now() { date -u +%Y-%m-%dT%H:%M:%S.000Z; } push() { api -X POST "$URL/api/sync" -d "$1" >/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 } cleanup() { kill $SERVER_PID 2>/dev/null "$DIR/stop.sh" pkill -f "^gnome-shell --(nested|headless)" 2>/dev/null wait 2>/dev/null } trap cleanup EXIT echo "=== Server starten" rm -rf "$SERVER_DATA" (cd "$ROOT/backend" && DATA_DIR="$SERVER_DATA" SYNC_TOKEN=test .venv/bin/uvicorn app.main:app --port $PORT > /tmp/desktop-app-sync-server.log 2>&1 &) for i in $(seq 1 30); do curl -s "$URL/api/health" >/dev/null && break; sleep 0.5; done SERVER_PID=$(pgrep -f "uvicorn app.main:app --port $PORT") check "Server läuft" "$(curl -s $URL/api/health)" '{"ok":true}' echo "=== Shell mit Sync starten" SYNC_URL="$URL" "$DIR/restart.sh" headless >/dev/null check "Sync aktiv" "$(val 'ext._sync.active')" "true" check "Ohne Aktivität Ruhetakt 10 s" "$(val 'ext._sync.interval')" "10" echo "=== Lokal → Server" "$E" "$PRE store.saveEvent({title: 'Zahnarzt', date: '2026-09-21', start: '09:00', end: '10:00', endDate: null}); store.saveTodo({title: 'Einkaufen', date: null, time: null}); 'ok'" >/dev/null sleep 3 check "Termin auf dem Server" "$(api "$URL/api/events" | python3 -c 'import json,sys; print([e["title"] for e in json.load(sys.stdin)])')" "['Zahnarzt']" check "Todo auf dem Server" "$(api "$URL/api/todos" | python3 -c 'import json,sys; print([t["title"] for t in json.load(sys.stdin)])')" "['Einkaufen']" check "Stand gemerkt" "$(val 'state().cursor > 0')" "true" check "Nach eigener Änderung Takt 1 s" "$(val 'ext._sync.interval')" "1" "$E" "$PRE store.saveTodo({title: 'Sofort', date: null, time: null}); 'ok'" >/dev/null START=$(date +%s%N) for i in $(seq 1 40); do api "$URL/api/todos" | grep -q '"Sofort"' && break; sleep 0.1; done MS=$(( ($(date +%s%N) - START) / 1000000 )) check "Eigene Änderung ist sofort auf dem Server" "$([ $MS -lt 1500 ] && echo "schnell" || echo "langsam: ${MS}ms")" "schnell" "$E" "$PRE store.removeTodo(store.todos.find(t => t.title === 'Sofort').id); 'ok'" >/dev/null sleep 2 echo "=== Server → lokal" push '{"todos": [{"id": "r1", "updated": "2026-09-19T10:00:00.000Z", "title": "Vom Handy", "date": null, "time": null, "done": false}], "notes": [{"id": "Remote/Idee.md", "updated": "2026-09-19T10:00:00.000Z", "content": "# Vom Server"}]}' sleep 3 check "Todo vom Server in der Liste" "$(val 'store.sortedTodos().map(t => t.title).sort().join(",")')" "Einkaufen,Vom Handy" "$E" "$PRE ext._sync._lastActivity = GLib.get_monotonic_time() - 70e6; 'ok'" >/dev/null check "Nach 70 s Ruhe Takt 5 s" "$(val 'ext._sync.interval')" "5" push '{"todos": [{"id": "r2", "updated": "2026-09-19T10:00:00.000Z", "title": "Weckt auf", "date": null, "time": null, "done": false}]}' sleep 7 check "Änderung vom Server setzt Takt auf 1 s" "$(val 'store.todos.some(t => t.id === "r2") + " " + ext._sync.interval')" "true 1" "$E" "$PRE ext._sync._lastActivity = GLib.get_monotonic_time() - 400e6; 'ok'" >/dev/null check "Nach 5 min Ruhe Takt 10 s" "$(val 'ext._sync.interval')" "10" # Aufräumen: Test-Todo weg, damit wieder schnell abgeglichen wird "$E" "$PRE store.removeTodo('r2'); 'ok'" >/dev/null sleep 2 check "Todo vom Server im Panel" "$(val 'labels(todos).includes("Vom Handy")')" "true" check "Notiz vom Server als Datei" "$(val 'exists(store.notesDir + "/Remote/Idee.md") + " " + JSON.stringify(read(store.notesDir + "/Remote/Idee.md"))')" 'true "# Vom Server"' check "Notiz vom Server im Baum" "$(val 'labels(notes).includes("Remote")')" "true" check "Übernommene Notiz wird nicht zurückgeschickt" "$(val 'state().pushed.notes["Remote/Idee.md"]')" "2026-09-19T10:00:00.000Z" echo "=== Konflikt: neuester gewinnt" push '{"todos": [{"id": "r1", "updated": "2026-09-19T09:00:00.000Z", "title": "Älter", "date": null, "time": null, "done": false}]}' sleep 3 check "Älterer Stand verliert" "$(val 'store.todos.find(t => t.id === "r1").title')" "Vom Handy" push '{"todos": [{"id": "r1", "updated": "'$(now)'", "title": "Neuer", "date": null, "time": null, "done": false}]}' sleep 3 check "Neuerer Stand gewinnt" "$(val 'store.todos.find(t => t.id === "r1").title')" "Neuer" sleep 1 "$E" "$PRE store.toggleTodo('r1'); 'ok'" >/dev/null sleep 3 check "Lokale Änderung ist neuer und landet auf dem Server" "$(api "$URL/api/todos" | python3 -c 'import json,sys; print([t["done"] for t in json.load(sys.stdin) if t["id"] == "r1"])')" "[True]" push '{"todos": [{"id": "r1", "updated": "2026-09-19T09:30:00.000Z", "title": "Verspätet", "date": null, "time": null, "done": false}]}' sleep 3 check "Verspäteter älterer Stand ändert nichts" "$(val 'store.todos.find(t => t.id === "r1").title + " " + store.todos.find(t => t.id === "r1").done')" "Neuer true" echo "=== Löschen in beide Richtungen" "$E" "$PRE store.removeTodo(store.todos.find(t => t.title === 'Einkaufen').id); store.deleteNote(store.notesDir + '/Remote/Idee.md'); 'ok'" >/dev/null sleep 3 check "Gelöschtes Todo auf dem Server weg" "$(api "$URL/api/todos" | python3 -c 'import json,sys; print(sorted(t["title"] for t in json.load(sys.stdin)))')" "['Neuer']" check "Gelöschte Notiz auf dem Server weg" "$(api "$URL/api/notes" | python3 -c 'import json,sys; print([n["id"] for n in json.load(sys.stdin)])')" "[]" "$E" "$PRE store.createNote(store.notesDir, 'Lokal'); store.writeNote(store.notesDir + '/Lokal.md', 'lokal'); 'ok'" >/dev/null sleep 3 check "Lokale Notiz auf dem Server" "$(api "$URL/api/notes" | python3 -c 'import json,sys; print([(n["id"], n["content"]) for n in json.load(sys.stdin)])')" "[('Lokal.md', 'lokal')]" push '{"notes": [{"id": "Lokal.md", "updated": "'$(now)'", "deleted": true}]}' sleep 3 check "Vom Server gelöschte Notiz lokal weg" "$(val 'exists(store.notesDir + "/Lokal.md")')" "false" echo "=== Offene Notiz bleibt unangetastet" "$E" "$PRE store.createNote(store.notesDir, 'Offen'); store.writeNote(store.notesDir + '/Offen.md', 'meins'); notes._select({type: 'note', path: store.notesDir + '/Offen.md', name: 'Offen'}); 'ok'" >/dev/null sleep 3 "$E" "$PRE notes._startEdit(); 'ok'" >/dev/null CURSOR=$(val 'state().cursor') push '{"notes": [{"id": "Offen.md", "updated": "'$(now)'", "content": "vom server"}]}' sleep 3 check "Datei während des Tippens unverändert" "$(val 'read(store.notesDir + "/Offen.md") + " " + (state().cursor === '"$CURSOR"')')" "meins true" "$E" "$PRE notes._stopEdit(); 'ok'" >/dev/null sleep 3 check "Nach dem Schließen übernommen" "$(val 'read(store.notesDir + "/Offen.md") + " " + notes._content.text')" "vom server vom server" echo "=== Umbenennen" sleep 1 "$E" "$PRE store.renameNote(store.notesDir + '/Offen.md', 'Neu'); 'ok'" >/dev/null sleep 3 check "Umbenannt auf dem Server" "$(api "$URL/api/notes" | python3 -c 'import json,sys; print([n["id"] for n in json.load(sys.stdin)])')" "['Neu.md']" echo "=== Ordner" folders() { api -X POST "$URL/api/sync" -d '{"since": 0}' | python3 -c 'import json,sys; print(sorted((f["id"], bool(f.get("deleted"))) for f in json.load(sys.stdin)["folders"]))'; } "$E" "$PRE store.createFolder(store.notesDir, 'Leer'); store.createFolder(store.notesDir + '/Leer', 'Tief'); 'ok'" >/dev/null sleep 3 check "Leere Ordner auf dem Server" "$(folders)" "[('Leer', False), ('Leer/Tief', False), ('Remote', False)]" push '{"folders": [{"id": "Vom Server/Sub", "updated": "'$(now)'", "deleted": false}]}' sleep 3 check "Ordner vom Server angelegt" "$(val 'GLib.file_test(store.notesDir + "/Vom Server/Sub", GLib.FileTest.IS_DIR) + " " + labels(notes).includes("Vom Server")')" "true true" "$E" "$PRE store.renameFolder(store.notesDir + '/Leer', 'Voll'); 'ok'" >/dev/null sleep 3 check "Ordner umbenannt auf dem Server" "$(folders)" "[('Leer', True), ('Leer/Tief', True), ('Remote', False), ('Voll', False), ('Voll/Tief', False), ('Vom Server', False), ('Vom Server/Sub', False)]" push '{"folders": [{"id": "Vom Server/Sub", "updated": "'$(now)'", "deleted": true}]}' sleep 3 check "Ordner vom Server gelöscht" "$(val 'GLib.file_test(store.notesDir + "/Vom Server/Sub", GLib.FileTest.EXISTS) + " " + GLib.file_test(store.notesDir + "/Vom Server", GLib.FileTest.IS_DIR)')" "false true" "$E" "$PRE store.deleteFolder(store.notesDir + '/Voll/Tief'); 'ok'" >/dev/null sleep 3 check "Lokal gelöschter Ordner auf dem Server als gelöscht" "$(folders | python3 -c 'import sys; print("Voll/Tief\x27, True" in sys.stdin.read())')" "True" echo "=== Dateien: Claude-Sitzungen zwischen PCs" FILES=/tmp/desktop-app-nested/claude-projects SFILES=$SERVER_DATA/files files() { api -X POST "$URL/api/sync" -d '{"since": 0, "files": []}' | python3 -c 'import json,sys; print(sorted((f["id"], bool(f.get("deleted"))) for f in json.load(sys.stdin)["files"]))'; } same() { cmp -s "$SFILES/$1" "$FILES/$1" && echo gleich || echo verschieden; } # Lauf wie vom Timer: Ruhezeit gilt timercycle() { "$E" "$PRE ext._sync._filesCycle(); 'ok'" >/dev/null; sleep 3; } # Wie make sync-files, nur auf dem Bus der Test-Shell: erzwungen, wartet, bis der Lauf durch ist. filescycle() { DBUS_SESSION_BUS_ADDRESS=$(cat /tmp/desktop-app-nested/bus) gdbus call --session --dest org.gnome.Shell --object-path /de/marha/desktop --method de.marha.Desktop.SyncFiles | sed -e "s/^('//" -e "s/',)$//"; } mkdir -p "$FILES/-home-x/memory" printf '{"a":1}\n' > "$FILES/-home-x/alt.jsonl"; touch -d '2 hours ago' "$FILES/-home-x/alt.jsonl" printf '# Memo\n' > "$FILES/-home-x/memory/MEMORY.md"; touch -d '90 minutes ago' "$FILES/-home-x/memory/MEMORY.md" printf 'läuft noch\n' > "$FILES/-home-x/frisch.jsonl" timercycle check "Timer-Lauf: ruhende Dateien auf dem Server, frische nicht" "$(files)" "[('-home-x/alt.jsonl', False), ('-home-x/memory/MEMORY.md', False)]" check "Inhalt liegt als Datei auf dem Server" "$(same -home-x/alt.jsonl) $(same -home-x/memory/MEMORY.md)" "gleich gleich" check "Takt nach Übertragung wie beim Kalender" "$(val 'ext._sync.interval')" "1" check "make sync-files erzwingt auch die frische Datei" "$(filescycle)" "hoch 1, gelöscht gemeldet 0, runter 0, lokal gelöscht 0" check "Frische Datei jetzt auf dem Server" "$(files)" "[('-home-x/alt.jsonl', False), ('-home-x/frisch.jsonl', False), ('-home-x/memory/MEMORY.md', False)]" check "Normale Anfrage bekommt keine Dateien" "$(api -X POST "$URL/api/sync" -d '{"since": 0}' | python3 -c 'import json,sys; d=json.load(sys.stdin); print("files" in d, "events" in d)')" "False True" check "Stand der Dateien mit Größe gemerkt" "$(val 'Object.keys(state().files.pushed).sort().join(",") + " " + (state().files.cursor > 0) + " " + state().files.pushed["-home-x/alt.jsonl"].size')" "-home-x/alt.jsonl,-home-x/frisch.jsonl,-home-x/memory/MEMORY.md true 8" printf '{"a":2}\n' >> "$FILES/-home-x/alt.jsonl"; touch -d '2 minutes ago' "$FILES/-home-x/alt.jsonl" timercycle check "Angehängte Zeile nach einer Minute Ruhe auf dem Server" "$(same -home-x/alt.jsonl) $(val 'state().files.pushed["-home-x/alt.jsonl"].size')" "gleich 16" printf 'neu\n' > "$FILES/-home-x/alt.jsonl"; touch -d '2 minutes ago' "$FILES/-home-x/alt.jsonl" timercycle check "Umgeschriebene Datei ganz ersetzt" "$(same -home-x/alt.jsonl) $(cat "$SFILES/-home-x/alt.jsonl")" "gleich neu" push '{"files": [{"id": "-home-y/s2.jsonl", "updated": "2026-09-01T00:00:00.000Z", "content": "vom anderen PC\n"}]}' check "Abgleich holt die Datei" "$(filescycle)" "hoch 0, gelöscht gemeldet 0, runter 1, lokal gelöscht 0" check "Datei vom Server geschrieben, Änderungszeit übernommen" "$(cat "$FILES/-home-y/s2.jsonl") $(stat -c %Y "$FILES/-home-y/s2.jsonl")" "vom anderen PC $(date -u -d 2026-09-01T00:00:00Z +%s)" H=$(printf 'vom anderen PC\n' | sha256sum | cut -d' ' -f1) push '{"files": [{"id": "-home-y/s2.jsonl", "updated": "2026-09-01T01:00:00.000Z", "base": {"size": 15, "hash": "'$H'"}, "append": "zweite Zeile\n"}]}' check "Anhang vom Server kommt nur als Rest" "$(filescycle) | $(tr '\n' '|' < "$FILES/-home-y/s2.jsonl")" "hoch 0, gelöscht gemeldet 0, runter 1, lokal gelöscht 0 | vom anderen PC|zweite Zeile|" printf 'meins\n' > "$FILES/-home-x/alt.jsonl"; touch -d '90 seconds ago' "$FILES/-home-x/alt.jsonl" push '{"files": [{"id": "-home-x/alt.jsonl", "updated": "'$(date -u -d '3 hours ago' +%Y-%m-%dT%H:%M:%S.000Z)'", "content": "veraltet"}]}' timercycle check "Lokal neuer gewinnt und geht hoch" "$(cat "$SFILES/-home-x/alt.jsonl")" "meins" rm "$FILES/-home-x/memory/MEMORY.md" push '{"files": [{"id": "-home-y/s2.jsonl", "updated": "'$(now)'", "deleted": true}]}' check "Abgleich meldet Löschungen" "$(filescycle)" "hoch 0, gelöscht gemeldet 1, runter 0, lokal gelöscht 1" check "Gelöscht in beide Richtungen, auch auf der Server-Platte" "$(files | python3 -c 'import sys; print("MEMORY.md\x27, True" in sys.stdin.read())') $([ -e "$FILES/-home-y/s2.jsonl" ] && echo da || echo weg) $([ -e "$SFILES/-home-x/memory/MEMORY.md" ] && echo da || echo weg)" "True weg weg" check "Gemeldete Löschung nicht nochmal" "$(val 'state().files.pushed["-home-x/memory/MEMORY.md"] === null && state().files.pushed["-home-y/s2.jsonl"] === null')" "true" check "Zweiter Lauf überträgt nichts" "$(filescycle)" "hoch 0, gelöscht gemeldet 0, runter 0, lokal gelöscht 0" check "Zweiter Lauf schickt nichts erneut" "$(files)" "[('-home-x/alt.jsonl', False), ('-home-x/frisch.jsonl', False), ('-home-x/memory/MEMORY.md', True), ('-home-y/s2.jsonl', True)]" echo "=== Server weg und wieder da" kill $SERVER_PID; sleep 1 "$E" "$PRE store.saveTodo({title: 'Offline', date: null, time: null}); 'ok'" >/dev/null sleep 3 (cd "$ROOT/backend" && DATA_DIR="$SERVER_DATA" SYNC_TOKEN=test .venv/bin/uvicorn app.main:app --port $PORT >> /tmp/desktop-app-sync-server.log 2>&1 &) for i in $(seq 1 30); do curl -s "$URL/api/health" >/dev/null && break; sleep 0.5; done SERVER_PID=$(pgrep -f "uvicorn app.main:app --port $PORT") sleep 4 check "Offline-Änderung nachgeliefert" "$(api "$URL/api/todos" | python3 -c 'import json,sys; print(sorted(t["title"] for t in json.load(sys.stdin)))')" "['Neuer', 'Offline']" 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 -30; grep "desktop-app sync" /tmp/desktop-app-nested.log | tail -5; fi