Files
desktop-planer/dev/tests/sync.sh
2026-09-22 13:28:52 +02:00

196 lines
14 KiB
Bash
Executable File

#!/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
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"]))'; }
# Wie make sync-files, nur auf dem Bus der Test-Shell. 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"
echo '{"a":1}' > "$FILES/-home-x/alt.jsonl"; touch -d '2 hours ago' "$FILES/-home-x/alt.jsonl"
echo '# Memo' > "$FILES/-home-x/memory/MEMORY.md"; touch -d '90 minutes ago' "$FILES/-home-x/memory/MEMORY.md"
echo 'läuft noch' > "$FILES/-home-x/frisch.jsonl"
check "Sofort-Abgleich meldet, was übertragen wurde" "$(filescycle)" "hoch 2, gelöscht gemeldet 0, runter 0, lokal gelöscht 0"
check "Ruhende Dateien auf dem Server, frische nicht" "$(files)" "[('-home-x/alt.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 getrennt gemerkt" "$(val 'Object.keys(state().files.pushed).sort().join(",") + " " + (state().files.cursor > 0)')" "-home-x/alt.jsonl,-home-x/memory/MEMORY.md true"
push '{"files": [{"id": "-home-y/s2.jsonl", "updated": "2026-09-01T00:00:00.000Z", "content": "vom anderen PC"}]}'
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)"
echo '{"a":2}' > "$FILES/-home-x/alt.jsonl"; touch -d '70 minutes 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"}]}'
filescycle >/dev/null
check "Lokal neuer gewinnt und geht hoch" "$(api -X POST "$URL/api/sync" -d '{"since": 0, "files": []}' | python3 -c 'import json,sys; print([f["content"].strip() for f in json.load(sys.stdin)["files"] if f["id"] == "-home-x/alt.jsonl"])')" "['{\"a\":2}']"
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" "$(files | python3 -c 'import sys; print("MEMORY.md\x27, True" in sys.stdin.read())') $([ -e "$FILES/-home-y/s2.jsonl" ] && echo da || echo weg)" "True 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/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