diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bada1a1..ec897f9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -193,6 +193,110 @@ jobs: if-no-files-found: warn retention-days: 7 + # ── Drive the actual app against actual nodes ─────────────────────────────── + # + # Everything above this line can pass for a client that never starts. The jobs + # compile it, unit test :shared, build a jar and a wheel, and check facts about + # PyPI -- and every defect this repo has shipped to the server team lived past + # all of that, because it only exists once the app is RUNNING and talking to a + # node. This job starts it, four times, against the four node configurations a + # universal client has to be complete against. + # + # See testing/README.md for the matrix and for the one corner it cannot cover. + e2e-desktop: + name: e2e (desktop) + needs: [gradle] + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: apt (hardened) + uses: ./.github/actions/apt + with: + # Compose Desktop needs a display. There is no X server on a runner. + # xvfb-run needs xauth; without it it fails with a bare "xauth: not found". + packages: xvfb xauth + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: actions/download-artifact@v4 + with: + name: gradle-client + path: jars + + - name: The node this client will be a client of + id: node + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + want="v$(cat VERSION)" + asset_for() { echo "ciris-server-$1-x86_64-unknown-linux-gnu.tar.gz"; } + mkdir -p node + # The pinned version first. Client and server versions are gated to + # move together, so that is the pairing this tree claims to work with. + if gh release download "$want" -R CIRISAI/CIRISServer \ + -p "$(asset_for "$want")" -D node 2>/dev/null; then + used="$want" + else + # NOT a silent fallback. The client can legitimately cut a version + # before the server tags the matching one, and testing against no + # node at all would be worse -- but which node ran is a fact the + # report has to carry, not a detail the log buries. + used=$(gh release view -R CIRISAI/CIRISServer --json tagName -q .tagName) + echo "::notice::CIRISServer $want is not released yet; the walk ran against $used" + gh release download "$used" -R CIRISAI/CIRISServer -p "$(asset_for "$used")" -D node + fi + tar xzf node/ciris-server-*.tar.gz -C node + chmod +x node/ciris-server + echo "version=$used" >> "$GITHUB_OUTPUT" + echo "Client $(cat VERSION) walking against node $used" + + - name: Walk the matrix + run: | + set -euo pipefail + jar=$(find jars -name '*.jar' -size +1M | head -1) + test -n "$jar" || { echo "::error::no uber-jar in the gradle artifact"; exit 1; } + python3 -m testing.run_e2e \ + --corner all \ + --jar "$jar" \ + --node-bin node/ciris-server \ + --node-version "${{ steps.node.outputs.version }}" \ + --report e2e-report.json \ + --workdir e2e-work + + - name: What the walk saw + if: always() + run: | + test -f e2e-report.json || { echo "no report was written"; exit 0; } + python3 - <<'EOF' + import json + r = json.load(open("e2e-report.json")) + for c in r["corners"]: + print(f"{c['corner']:24s} {c['status']:8s} {c['seconds']}s screen={c['screen']!r}") + for case in c["cases"]: + if case["status"] == "failed": + print(f" FAILED {case['name']}: {case['detail']}") + EOF + + - uses: actions/upload-artifact@v4 + if: always() + with: + # Screenshots, node logs and app logs for every corner. A failed walk + # is only useful if you can see what the app was showing. + name: e2e-evidence + path: | + e2e-report.json + e2e-work/*/app.log + e2e-work/*/node.log + e2e-work/*/failure.png + if-no-files-found: warn + retention-days: 14 + # ── Package. Runs whatever gradle managed, and says so ────────────────────── wheels: name: wheels diff --git a/client/VENDORING.md b/client/VENDORING.md index 6da0b6b..03e3227 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `52941ae71c8cedf8b134f6464698fa82432b2e17700aff972aa02ac3a1e9030a` +**state digest:** `b07c80c55503d4082466878ab3fed24027983f88e45ad2371212b785cdf2ea63` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/androidApp/src/main/assets/localization/am.json b/client/androidApp/src/main/assets/localization/am.json index b478e20..55d8b26 100644 --- a/client/androidApp/src/main/assets/localization/am.json +++ b/client/androidApp/src/main/assets/localization/am.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "ማስተላለፍ ተሳክቷል! TX: {tx}", "wallet_trust_degraded": "ስልክ ሥቅ ተዳክሞ ነበር", "wallet_warning": "ማስጠንቀቂያ", + "claim_node_no_signer": "የእርስዎ ኖድ ሌላ ኖድ ከመያዙ በፊት መጀመር አለበት። ኖድዎን ይጀምሩ፣ ከዚያ እንደገና ይሞክሩ።", "attestation_actions_desc": "ለዚህ መዝገብ ያሉ እርምጃዎች", "chat_badge_message": "መልዕክት", "chat_details": "ዓይነት {type} · ወሰን {scope} · ሁኔታ {status}", diff --git a/client/androidApp/src/main/assets/localization/ar.json b/client/androidApp/src/main/assets/localization/ar.json index 3b025d4..31d7e78 100644 --- a/client/androidApp/src/main/assets/localization/ar.json +++ b/client/androidApp/src/main/assets/localization/ar.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "نجح التحويل! TX: {tx}", "wallet_trust_degraded": "تدهورت ثقة الأجهزة", "wallet_warning": "تحذير", + "claim_node_no_signer": "يجب تشغيل عقدتك قبل أن تتمكن من المطالبة بعقدة أخرى. شغّل عقدتك، ثم حاول مرة أخرى.", "attestation_actions_desc": "إجراءات هذا السجل", "chat_badge_message": "رسالة", "chat_details": "النوع {type} · النطاق {scope} · الحالة {status}", diff --git a/client/androidApp/src/main/assets/localization/bn.json b/client/androidApp/src/main/assets/localization/bn.json index 30f1052..744a4b8 100644 --- a/client/androidApp/src/main/assets/localization/bn.json +++ b/client/androidApp/src/main/assets/localization/bn.json @@ -2925,6 +2925,7 @@ "wallet_transfer_success": "ট্রান্সফার সফল! TX: {tx}", "wallet_trust_degraded": "হার্ডওয়্যার বিশ্বাস হ্রাস পেয়েছে", "wallet_warning": "সতর্কতা", + "claim_node_no_signer": "আরেকটি নোড দাবি করার আগে আপনার নোড চালু থাকতে হবে। আপনার নোড চালু করুন, তারপর আবার চেষ্টা করুন।", "attestation_actions_desc": "এই রেকর্ডের জন্য পদক্ষেপ", "chat_badge_message": "বার্তা", "chat_details": "ধরন {type} · পরিসর {scope} · অবস্থা {status}", diff --git a/client/androidApp/src/main/assets/localization/de.json b/client/androidApp/src/main/assets/localization/de.json index 74e684c..1bb19cb 100644 --- a/client/androidApp/src/main/assets/localization/de.json +++ b/client/androidApp/src/main/assets/localization/de.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Überweisung erfolgreich! TX: {tx}", "wallet_trust_degraded": "Hardware-Vertrauen herabgestuft", "wallet_warning": "Warnung", + "claim_node_no_signer": "Ihr Node muss gestartet sein, bevor er einen anderen Knoten übernehmen kann. Starten Sie Ihren Node und versuchen Sie es erneut.", "attestation_actions_desc": "Aktionen für diesen Datensatz", "chat_badge_message": "NACHRICHT", "chat_details": "Typ {type} · Bereich {scope} · Status {status}", diff --git a/client/androidApp/src/main/assets/localization/en.json b/client/androidApp/src/main/assets/localization/en.json index 8561878..336913c 100644 --- a/client/androidApp/src/main/assets/localization/en.json +++ b/client/androidApp/src/main/assets/localization/en.json @@ -2993,7 +2993,8 @@ "wallet_transfer_note": "Transfer to another Base address for fiat cashout", "wallet_transfer_success": "Transfer successful! TX: {tx}", "wallet_trust_degraded": "Hardware Trust Degraded", - "wallet_warning": "Warning" + "wallet_warning": "Warning", + "claim_node_no_signer": "Your node must be started before it can claim another node. Start your node, then try again." }, "moderation": { "ladder": { diff --git a/client/androidApp/src/main/assets/localization/es.json b/client/androidApp/src/main/assets/localization/es.json index 66a6762..6b600c2 100644 --- a/client/androidApp/src/main/assets/localization/es.json +++ b/client/androidApp/src/main/assets/localization/es.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "¡Transferencia exitosa! TX: {tx}", "wallet_trust_degraded": "Confianza de Hardware Degradada", "wallet_warning": "Advertencia", + "claim_node_no_signer": "Tu nodo debe estar iniciado antes de poder reclamar otro nodo. Inicia tu nodo e inténtalo de nuevo.", "attestation_actions_desc": "Acciones para este registro", "chat_badge_message": "MENSAJE", "chat_details": "tipo {type} · ámbito {scope} · estado {status}", diff --git a/client/androidApp/src/main/assets/localization/fa.json b/client/androidApp/src/main/assets/localization/fa.json index d9caea0..0fda01b 100644 --- a/client/androidApp/src/main/assets/localization/fa.json +++ b/client/androidApp/src/main/assets/localization/fa.json @@ -2930,6 +2930,7 @@ "wallet_transfer_success": "انتقال با موفقیت انجام شد! TX: {tx}", "wallet_trust_degraded": "افت اعتماد سخت‌افزاری", "wallet_warning": "هشدار", + "claim_node_no_signer": "گرهٔ شما باید پیش از ادعای گره‌ای دیگر راه‌اندازی شده باشد. گرهٔ خود را راه‌اندازی کنید و دوباره تلاش کنید.", "attestation_actions_desc": "اقدامات برای این رکورد", "chat_badge_message": "پیام", "chat_details": "نوع {type} · محدوده {scope} · وضعیت {status}", diff --git a/client/androidApp/src/main/assets/localization/fr.json b/client/androidApp/src/main/assets/localization/fr.json index 10abf12..5716835 100644 --- a/client/androidApp/src/main/assets/localization/fr.json +++ b/client/androidApp/src/main/assets/localization/fr.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfert réussi ! TX: {tx}", "wallet_trust_degraded": "Confiance Matérielle Dégradée", "wallet_warning": "Avertissement", + "claim_node_no_signer": "Votre nœud doit être démarré avant de pouvoir revendiquer un autre nœud. Démarrez votre nœud, puis réessayez.", "attestation_actions_desc": "Actions pour cet enregistrement", "chat_badge_message": "MESSAGE", "chat_details": "type {type} · portée {scope} · statut {status}", diff --git a/client/androidApp/src/main/assets/localization/ha.json b/client/androidApp/src/main/assets/localization/ha.json index 7ac4491..79baae8 100644 --- a/client/androidApp/src/main/assets/localization/ha.json +++ b/client/androidApp/src/main/assets/localization/ha.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Aika ya yi nasara! TX: {tx}", "wallet_trust_degraded": "Tabbas Kayan Aiki Ya Rage", "wallet_warning": "Gargaɗi", + "claim_node_no_signer": "Dole ne a fara kumburinka kafin ya iya karɓar wani kumburi. Fara kumburinka, sannan ka sake gwadawa.", "attestation_actions_desc": "Ayyuka don wannan rikodin", "chat_badge_message": "SAƘO", "chat_details": "nau'i {type} · iyaka {scope} · matsayi {status}", diff --git a/client/androidApp/src/main/assets/localization/hi.json b/client/androidApp/src/main/assets/localization/hi.json index 7654f45..d483015 100644 --- a/client/androidApp/src/main/assets/localization/hi.json +++ b/client/androidApp/src/main/assets/localization/hi.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "स्थानांतरण सफल! TX: {tx}", "wallet_trust_degraded": "हार्डवेयर ट्रस्ट कम हुआ", "wallet_warning": "चेतावनी", + "claim_node_no_signer": "किसी अन्य नोड का दावा करने से पहले आपका नोड शुरू होना चाहिए। अपना नोड शुरू करें, फिर दोबारा प्रयास करें।", "attestation_actions_desc": "इस रिकॉर्ड के लिए कार्रवाइयाँ", "chat_badge_message": "संदेश", "chat_details": "प्रकार {type} · दायरा {scope} · स्थिति {status}", diff --git a/client/androidApp/src/main/assets/localization/id.json b/client/androidApp/src/main/assets/localization/id.json index 809bf6a..aa8a9af 100644 --- a/client/androidApp/src/main/assets/localization/id.json +++ b/client/androidApp/src/main/assets/localization/id.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer berhasil! TX: {tx}", "wallet_trust_degraded": "Kepercayaan Perangkat Keras Menurun", "wallet_warning": "Peringatan", + "claim_node_no_signer": "Node Anda harus dijalankan terlebih dahulu sebelum dapat mengklaim node lain. Jalankan node Anda, lalu coba lagi.", "attestation_actions_desc": "Tindakan untuk catatan ini", "chat_badge_message": "PESAN", "chat_details": "jenis {type} · lingkup {scope} · status {status}", diff --git a/client/androidApp/src/main/assets/localization/it.json b/client/androidApp/src/main/assets/localization/it.json index 1748af9..38dd3e2 100644 --- a/client/androidApp/src/main/assets/localization/it.json +++ b/client/androidApp/src/main/assets/localization/it.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Trasferimento riuscito! TX: {tx}", "wallet_trust_degraded": "Fiducia Hardware Degradata", "wallet_warning": "Avviso", + "claim_node_no_signer": "Il tuo nodo deve essere avviato prima di poter rivendicare un altro nodo. Avvia il tuo nodo, quindi riprova.", "attestation_actions_desc": "Azioni per questo record", "chat_badge_message": "MESSAGGIO", "chat_details": "tipo {type} · ambito {scope} · stato {status}", diff --git a/client/androidApp/src/main/assets/localization/ja.json b/client/androidApp/src/main/assets/localization/ja.json index 07f3851..1dfa258 100644 --- a/client/androidApp/src/main/assets/localization/ja.json +++ b/client/androidApp/src/main/assets/localization/ja.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "送金成功!TX: {tx}", "wallet_trust_degraded": "ハードウェア信頼が低下しました", "wallet_warning": "警告", + "claim_node_no_signer": "他のノードの所有権を取得するには、まずお客様のノードを起動する必要があります。ノードを起動してから、もう一度お試しください。", "attestation_actions_desc": "このレコードに対する操作", "chat_badge_message": "メッセージ", "chat_details": "種別 {type} · スコープ {scope} · 状態 {status}", diff --git a/client/androidApp/src/main/assets/localization/ko.json b/client/androidApp/src/main/assets/localization/ko.json index a766a42..ac48476 100644 --- a/client/androidApp/src/main/assets/localization/ko.json +++ b/client/androidApp/src/main/assets/localization/ko.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "전송 성공! TX: {tx}", "wallet_trust_degraded": "하드웨어 신뢰 저하됨", "wallet_warning": "경고", + "claim_node_no_signer": "노드를 시작해야 다른 노드의 소유권을 주장할 수 있습니다. 노드를 시작한 후 다시 시도하세요.", "attestation_actions_desc": "이 레코드에 대한 작업", "chat_badge_message": "메시지", "chat_details": "유형 {type} · 범위 {scope} · 상태 {status}", diff --git a/client/androidApp/src/main/assets/localization/mr.json b/client/androidApp/src/main/assets/localization/mr.json index 0c7cf1f..2fa2e8f 100644 --- a/client/androidApp/src/main/assets/localization/mr.json +++ b/client/androidApp/src/main/assets/localization/mr.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer यशful! TX: {tx}", "wallet_trust_degraded": "हार्डवेअर विश्वास खालावला", "wallet_warning": "इशारा", + "claim_node_no_signer": "आणखी एका नोडचा दावा करण्यापूर्वी तुमचा नोड सुरू असणे आवश्यक आहे. तुमचा नोड सुरू करा आणि नंतर पुन्हा प्रयत्न करा.", "attestation_actions_desc": "या नोंदीसाठी क्रिया", "chat_badge_message": "संदेश", "chat_details": "प्रकार {type} · व्याप्ती {scope} · स्थिती {status}", diff --git a/client/androidApp/src/main/assets/localization/my.json b/client/androidApp/src/main/assets/localization/my.json index aa01418..72cc1a3 100644 --- a/client/androidApp/src/main/assets/localization/my.json +++ b/client/androidApp/src/main/assets/localization/my.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer အောင်မြင်ful! TX: {tx}", "wallet_trust_degraded": "Hardware ယုံကြည်မှု အားနည်းသွားပြီ", "wallet_warning": "သတိပေး", + "claim_node_no_signer": "အခြား Node ကို ပိုင်ဆိုင်မှုပြောရန် သင်၏ node ကို အရင်စတင်ထားရမည်။ သင်၏ node ကို စတင်ပြီး ထပ်မံကြိုးစားပါ။", "attestation_actions_desc": "ဤမှတ်တမ်းအတွက် လုပ်ဆောင်ချက်များ", "chat_badge_message": "မက်ဆေ့ချ်", "chat_details": "အမျိုးအစား {type} · scope {scope} · အခြေအနေ {status}", diff --git a/client/androidApp/src/main/assets/localization/pa.json b/client/androidApp/src/main/assets/localization/pa.json index ebf8145..da6082d 100644 --- a/client/androidApp/src/main/assets/localization/pa.json +++ b/client/androidApp/src/main/assets/localization/pa.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "ਟ੍ਰਾਂਸਫਰ ਸਫਲ! TX: {tx}", "wallet_trust_degraded": "ਹਾਰਡਵੇਅਰ ਭਰੋਸਾ ਘਟਿਆ", "wallet_warning": "ਚੇਤਾਵਨੀ", + "claim_node_no_signer": "ਕਿਸੇ ਹੋਰ ਨੋਡ ਦਾ ਦਾਅਵਾ ਕਰਨ ਤੋਂ ਪਹਿਲਾਂ ਤੁਹਾਡਾ ਨੋਡ ਸ਼ੁਰੂ ਹੋਣਾ ਲਾਜ਼ਮੀ ਹੈ। ਆਪਣਾ ਨੋਡ ਸ਼ੁਰੂ ਕਰੋ, ਫਿਰ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।", "attestation_actions_desc": "ਇਸ ਰਿਕਾਰਡ ਲਈ ਕਾਰਵਾਈਆਂ", "chat_badge_message": "ਸੁਨੇਹਾ", "chat_details": "ਕਿਸਮ {type} · ਦਾਇਰਾ {scope} · ਸਥਿਤੀ {status}", diff --git a/client/androidApp/src/main/assets/localization/pt.json b/client/androidApp/src/main/assets/localization/pt.json index fa5fa8c..d00c840 100644 --- a/client/androidApp/src/main/assets/localization/pt.json +++ b/client/androidApp/src/main/assets/localization/pt.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transferência bem-sucedida! TX: {tx}", "wallet_trust_degraded": "Confiança de Hardware Degradada", "wallet_warning": "Aviso", + "claim_node_no_signer": "Seu nó precisa ser iniciado antes que possa reivindicar outro nó. Inicie seu nó e tente novamente.", "attestation_actions_desc": "Ações para este registo", "chat_badge_message": "MENSAGEM", "chat_details": "tipo {type} · âmbito {scope} · estado {status}", diff --git a/client/androidApp/src/main/assets/localization/ru.json b/client/androidApp/src/main/assets/localization/ru.json index 484e28e..b4bbaeb 100644 --- a/client/androidApp/src/main/assets/localization/ru.json +++ b/client/androidApp/src/main/assets/localization/ru.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Перевод успешен! TX: {tx}", "wallet_trust_degraded": "Доверие к оборудованию ухудшено", "wallet_warning": "Предупреждение", + "claim_node_no_signer": "Ваш узел должен быть запущен, прежде чем он сможет заявить права на другой узел. Запустите узел и повторите попытку.", "attestation_actions_desc": "Действия для этой записи", "chat_badge_message": "СООБЩЕНИЕ", "chat_details": "тип {type} · область {scope} · статус {status}", diff --git a/client/androidApp/src/main/assets/localization/sw.json b/client/androidApp/src/main/assets/localization/sw.json index 522bfde..f05a8e1 100644 --- a/client/androidApp/src/main/assets/localization/sw.json +++ b/client/androidApp/src/main/assets/localization/sw.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Uhamisho umefanikiwa! TX: {tx}", "wallet_trust_degraded": "Kuaminika kwa Maunzi Kumedhoofika", "wallet_warning": "Onyo", + "claim_node_no_signer": "Nodi yako inahitaji kuanzishwa kabla ya kudai nodi nyingine. Anzisha nodi yako, kisha ujaribu tena.", "attestation_actions_desc": "Vitendo vya rekodi hii", "chat_badge_message": "UJUMBE", "chat_details": "aina {type} · wigo {scope} · hali {status}", diff --git a/client/androidApp/src/main/assets/localization/ta.json b/client/androidApp/src/main/assets/localization/ta.json index 34f5c0e..12dc310 100644 --- a/client/androidApp/src/main/assets/localization/ta.json +++ b/client/androidApp/src/main/assets/localization/ta.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "பரிமாற்றம் வெற்றிகரம்! TX: {tx}", "wallet_trust_degraded": "Hardware நம்பகத்தன்மை Degraded", "wallet_warning": "எச்சரிக்கை", + "claim_node_no_signer": "உங்கள் நோடு மற்றொரு நோடைக் கோரும் முன் தொடங்கப்பட்டிருக்க வேண்டும். உங்கள் நோடைத் தொடங்கி, மீண்டும் முயற்சிக்கவும்.", "attestation_actions_desc": "இந்தப் பதிவுக்கான செயல்கள்", "chat_badge_message": "செய்தி", "chat_details": "வகை {type} · எல்லை {scope} · நிலை {status}", diff --git a/client/androidApp/src/main/assets/localization/te.json b/client/androidApp/src/main/assets/localization/te.json index 78b6759..6a6cd1b 100644 --- a/client/androidApp/src/main/assets/localization/te.json +++ b/client/androidApp/src/main/assets/localization/te.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "బదిలీ విజయవంతమైంది! TX: {tx}", "wallet_trust_degraded": "హార్డ్‌వేర్ నమ్మకం తగ్గింది", "wallet_warning": "హెచ్చరిక", + "claim_node_no_signer": "మీ నోడ్ మరో నోడ్‌ను క్లెయిమ్ చేయడానికి ముందు ప్రారంభమై ఉండాలి. మీ నోడ్‌ను ప్రారంభించి, మళ్ళీ ప్రయత్నించండి.", "attestation_actions_desc": "ఈ రికార్డుకు చర్యలు", "chat_badge_message": "సందేశం", "chat_details": "రకం {type} · పరిధి {scope} · స్థితి {status}", diff --git a/client/androidApp/src/main/assets/localization/th.json b/client/androidApp/src/main/assets/localization/th.json index 78de09e..4cbc211 100644 --- a/client/androidApp/src/main/assets/localization/th.json +++ b/client/androidApp/src/main/assets/localization/th.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer สำเร็จful! TX: {tx}", "wallet_trust_degraded": "ความน่าเชื่อถือของฮาร์ดแวร์ลดลง", "wallet_warning": "คำเตือน", + "claim_node_no_signer": "โหนดของคุณต้องเริ่มทำงานก่อนจึงจะสามารถอ้างสิทธิ์โหนดอื่นได้ กรุณาเริ่มโหนดของคุณ แล้วลองอีกครั้ง", "attestation_actions_desc": "การดำเนินการสำหรับระเบียนนี้", "chat_badge_message": "ข้อความ", "chat_details": "ประเภท {type} · ขอบเขต {scope} · สถานะ {status}", diff --git a/client/androidApp/src/main/assets/localization/tr.json b/client/androidApp/src/main/assets/localization/tr.json index 9708b87..443adfc 100644 --- a/client/androidApp/src/main/assets/localization/tr.json +++ b/client/androidApp/src/main/assets/localization/tr.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer başarılı! TX: {tx}", "wallet_trust_degraded": "Donanım Güveni Düşmüş", "wallet_warning": "Uyarı", + "claim_node_no_signer": "Başka bir düğümü sahiplenmeden önce kendi düğümünüz başlatılmış olmalıdır. Düğümünüzü başlatın ve tekrar deneyin.", "attestation_actions_desc": "Bu kayıt için eylemler", "chat_badge_message": "MESAJ", "chat_details": "tür {type} · kapsam {scope} · durum {status}", diff --git a/client/androidApp/src/main/assets/localization/uk.json b/client/androidApp/src/main/assets/localization/uk.json index fafcb63..a3ebdd0 100644 --- a/client/androidApp/src/main/assets/localization/uk.json +++ b/client/androidApp/src/main/assets/localization/uk.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Переказ успішний! TX: {tx}", "wallet_trust_degraded": "Апаратну довіру знижено", "wallet_warning": "Попередження", + "claim_node_no_signer": "Ваш вузол потрібно запустити, перш ніж він зможе заявити право на інший вузол. Запустіть свій вузол і спробуйте ще раз.", "attestation_actions_desc": "Дії для цього запису", "chat_badge_message": "ПОВІДОМЛЕННЯ", "chat_details": "тип {type} · область {scope} · статус {status}", diff --git a/client/androidApp/src/main/assets/localization/ur.json b/client/androidApp/src/main/assets/localization/ur.json index 7c17d31..39b48e6 100644 --- a/client/androidApp/src/main/assets/localization/ur.json +++ b/client/androidApp/src/main/assets/localization/ur.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "ٹرانسفر کامیاب! TX: {tx}", "wallet_trust_degraded": "ہارڈ ویئر اعتماد کمزور پڑ گیا", "wallet_warning": "انتباہ", + "claim_node_no_signer": "کسی اور نوڈ کا دعویٰ کرنے سے پہلے آپ کا نوڈ شروع ہونا ضروری ہے۔ اپنا نوڈ شروع کریں، پھر دوبارہ کوشش کریں۔", "attestation_actions_desc": "اس ریکارڈ کے لیے اقدامات", "chat_badge_message": "پیغام", "chat_details": "قسم {type} · دائرہ {scope} · حیثیت {status}", diff --git a/client/androidApp/src/main/assets/localization/vi.json b/client/androidApp/src/main/assets/localization/vi.json index 245869a..018b388 100644 --- a/client/androidApp/src/main/assets/localization/vi.json +++ b/client/androidApp/src/main/assets/localization/vi.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Chuyển khoản thành công! TX: {tx}", "wallet_trust_degraded": "Tin tưởng Suy giảm", "wallet_warning": "Cảnh báo", + "claim_node_no_signer": "Nút của bạn phải được khởi động trước khi có thể nhận quyền một nút khác. Hãy khởi động nút của bạn, rồi thử lại.", "attestation_actions_desc": "Các thao tác cho bản ghi này", "chat_badge_message": "TIN NHẮN", "chat_details": "loại {type} · phạm vi {scope} · trạng thái {status}", diff --git a/client/androidApp/src/main/assets/localization/yo.json b/client/androidApp/src/main/assets/localization/yo.json index d7f8400..0ebc93f 100644 --- a/client/androidApp/src/main/assets/localization/yo.json +++ b/client/androidApp/src/main/assets/localization/yo.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Gbígbé ṣe àṣeyọrí! TX: {tx}", "wallet_trust_degraded": "Ìgbẹ́kẹ̀lé Ohun Ẹ̀rọ Ti Dín Kù", "wallet_warning": "Ìkìlọ̀", + "claim_node_no_signer": "Gbọ́dọ̀ bẹ̀rẹ̀ node rẹ kí ó tó lè gba node mìíràn. Bẹ̀rẹ̀ node rẹ, lẹ́yìn náà gbìyànjú lẹ́ẹ̀kan sí i.", "attestation_actions_desc": "Àwọn ìgbésẹ̀ fún àkọsílẹ̀ yìí", "chat_badge_message": "ÌFIRÁNṢẸ́", "chat_details": "irú {type} · ààlà {scope} · ipò {status}", diff --git a/client/androidApp/src/main/assets/localization/zh.json b/client/androidApp/src/main/assets/localization/zh.json index 2bcede3..060d438 100644 --- a/client/androidApp/src/main/assets/localization/zh.json +++ b/client/androidApp/src/main/assets/localization/zh.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "转账成功!TX: {tx}", "wallet_trust_degraded": "硬件信任降级", "wallet_warning": "警告", + "claim_node_no_signer": "您的节点必须先启动才能认领另一个节点。请启动您的节点,然后重试。", "attestation_actions_desc": "此记录的操作", "chat_badge_message": "消息", "chat_details": "类型 {type} · 范围 {scope} · 状态 {status}", diff --git a/client/desktopApp/src/main/kotlin/ai/ciris/desktop/testing/TestAutomationServer.kt b/client/desktopApp/src/main/kotlin/ai/ciris/desktop/testing/TestAutomationServer.kt index 02507f3..d9a34f6 100644 --- a/client/desktopApp/src/main/kotlin/ai/ciris/desktop/testing/TestAutomationServer.kt +++ b/client/desktopApp/src/main/kotlin/ai/ciris/desktop/testing/TestAutomationServer.kt @@ -224,6 +224,16 @@ class TestAutomationServer( )) } + // The app's own account of its gates -- see StateResponse. + get("/state") { + call.respond(ai.ciris.mobile.shared.testing.StateResponse( + screen = currentScreen, + testMode = true, + clientMode = ai.ciris.mobile.shared.testing.TestAutomationState.clientMode, + nodeUrl = ai.ciris.mobile.shared.testing.TestAutomationState.nodeUrl + )) + } + // Get current screen get("/screen") { call.respond(ScreenResponse(screen = currentScreen)) diff --git a/client/desktopApp/src/main/resources/localization/am.json b/client/desktopApp/src/main/resources/localization/am.json index b478e20..55d8b26 100644 --- a/client/desktopApp/src/main/resources/localization/am.json +++ b/client/desktopApp/src/main/resources/localization/am.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "ማስተላለፍ ተሳክቷል! TX: {tx}", "wallet_trust_degraded": "ስልክ ሥቅ ተዳክሞ ነበር", "wallet_warning": "ማስጠንቀቂያ", + "claim_node_no_signer": "የእርስዎ ኖድ ሌላ ኖድ ከመያዙ በፊት መጀመር አለበት። ኖድዎን ይጀምሩ፣ ከዚያ እንደገና ይሞክሩ።", "attestation_actions_desc": "ለዚህ መዝገብ ያሉ እርምጃዎች", "chat_badge_message": "መልዕክት", "chat_details": "ዓይነት {type} · ወሰን {scope} · ሁኔታ {status}", diff --git a/client/desktopApp/src/main/resources/localization/ar.json b/client/desktopApp/src/main/resources/localization/ar.json index 3b025d4..31d7e78 100644 --- a/client/desktopApp/src/main/resources/localization/ar.json +++ b/client/desktopApp/src/main/resources/localization/ar.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "نجح التحويل! TX: {tx}", "wallet_trust_degraded": "تدهورت ثقة الأجهزة", "wallet_warning": "تحذير", + "claim_node_no_signer": "يجب تشغيل عقدتك قبل أن تتمكن من المطالبة بعقدة أخرى. شغّل عقدتك، ثم حاول مرة أخرى.", "attestation_actions_desc": "إجراءات هذا السجل", "chat_badge_message": "رسالة", "chat_details": "النوع {type} · النطاق {scope} · الحالة {status}", diff --git a/client/desktopApp/src/main/resources/localization/bn.json b/client/desktopApp/src/main/resources/localization/bn.json index 30f1052..744a4b8 100644 --- a/client/desktopApp/src/main/resources/localization/bn.json +++ b/client/desktopApp/src/main/resources/localization/bn.json @@ -2925,6 +2925,7 @@ "wallet_transfer_success": "ট্রান্সফার সফল! TX: {tx}", "wallet_trust_degraded": "হার্ডওয়্যার বিশ্বাস হ্রাস পেয়েছে", "wallet_warning": "সতর্কতা", + "claim_node_no_signer": "আরেকটি নোড দাবি করার আগে আপনার নোড চালু থাকতে হবে। আপনার নোড চালু করুন, তারপর আবার চেষ্টা করুন।", "attestation_actions_desc": "এই রেকর্ডের জন্য পদক্ষেপ", "chat_badge_message": "বার্তা", "chat_details": "ধরন {type} · পরিসর {scope} · অবস্থা {status}", diff --git a/client/desktopApp/src/main/resources/localization/de.json b/client/desktopApp/src/main/resources/localization/de.json index 74e684c..1bb19cb 100644 --- a/client/desktopApp/src/main/resources/localization/de.json +++ b/client/desktopApp/src/main/resources/localization/de.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Überweisung erfolgreich! TX: {tx}", "wallet_trust_degraded": "Hardware-Vertrauen herabgestuft", "wallet_warning": "Warnung", + "claim_node_no_signer": "Ihr Node muss gestartet sein, bevor er einen anderen Knoten übernehmen kann. Starten Sie Ihren Node und versuchen Sie es erneut.", "attestation_actions_desc": "Aktionen für diesen Datensatz", "chat_badge_message": "NACHRICHT", "chat_details": "Typ {type} · Bereich {scope} · Status {status}", diff --git a/client/desktopApp/src/main/resources/localization/en.json b/client/desktopApp/src/main/resources/localization/en.json index 8561878..336913c 100644 --- a/client/desktopApp/src/main/resources/localization/en.json +++ b/client/desktopApp/src/main/resources/localization/en.json @@ -2993,7 +2993,8 @@ "wallet_transfer_note": "Transfer to another Base address for fiat cashout", "wallet_transfer_success": "Transfer successful! TX: {tx}", "wallet_trust_degraded": "Hardware Trust Degraded", - "wallet_warning": "Warning" + "wallet_warning": "Warning", + "claim_node_no_signer": "Your node must be started before it can claim another node. Start your node, then try again." }, "moderation": { "ladder": { diff --git a/client/desktopApp/src/main/resources/localization/es.json b/client/desktopApp/src/main/resources/localization/es.json index 66a6762..6b600c2 100644 --- a/client/desktopApp/src/main/resources/localization/es.json +++ b/client/desktopApp/src/main/resources/localization/es.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "¡Transferencia exitosa! TX: {tx}", "wallet_trust_degraded": "Confianza de Hardware Degradada", "wallet_warning": "Advertencia", + "claim_node_no_signer": "Tu nodo debe estar iniciado antes de poder reclamar otro nodo. Inicia tu nodo e inténtalo de nuevo.", "attestation_actions_desc": "Acciones para este registro", "chat_badge_message": "MENSAJE", "chat_details": "tipo {type} · ámbito {scope} · estado {status}", diff --git a/client/desktopApp/src/main/resources/localization/fa.json b/client/desktopApp/src/main/resources/localization/fa.json index d9caea0..0fda01b 100644 --- a/client/desktopApp/src/main/resources/localization/fa.json +++ b/client/desktopApp/src/main/resources/localization/fa.json @@ -2930,6 +2930,7 @@ "wallet_transfer_success": "انتقال با موفقیت انجام شد! TX: {tx}", "wallet_trust_degraded": "افت اعتماد سخت‌افزاری", "wallet_warning": "هشدار", + "claim_node_no_signer": "گرهٔ شما باید پیش از ادعای گره‌ای دیگر راه‌اندازی شده باشد. گرهٔ خود را راه‌اندازی کنید و دوباره تلاش کنید.", "attestation_actions_desc": "اقدامات برای این رکورد", "chat_badge_message": "پیام", "chat_details": "نوع {type} · محدوده {scope} · وضعیت {status}", diff --git a/client/desktopApp/src/main/resources/localization/fr.json b/client/desktopApp/src/main/resources/localization/fr.json index 10abf12..5716835 100644 --- a/client/desktopApp/src/main/resources/localization/fr.json +++ b/client/desktopApp/src/main/resources/localization/fr.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfert réussi ! TX: {tx}", "wallet_trust_degraded": "Confiance Matérielle Dégradée", "wallet_warning": "Avertissement", + "claim_node_no_signer": "Votre nœud doit être démarré avant de pouvoir revendiquer un autre nœud. Démarrez votre nœud, puis réessayez.", "attestation_actions_desc": "Actions pour cet enregistrement", "chat_badge_message": "MESSAGE", "chat_details": "type {type} · portée {scope} · statut {status}", diff --git a/client/desktopApp/src/main/resources/localization/ha.json b/client/desktopApp/src/main/resources/localization/ha.json index 7ac4491..79baae8 100644 --- a/client/desktopApp/src/main/resources/localization/ha.json +++ b/client/desktopApp/src/main/resources/localization/ha.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Aika ya yi nasara! TX: {tx}", "wallet_trust_degraded": "Tabbas Kayan Aiki Ya Rage", "wallet_warning": "Gargaɗi", + "claim_node_no_signer": "Dole ne a fara kumburinka kafin ya iya karɓar wani kumburi. Fara kumburinka, sannan ka sake gwadawa.", "attestation_actions_desc": "Ayyuka don wannan rikodin", "chat_badge_message": "SAƘO", "chat_details": "nau'i {type} · iyaka {scope} · matsayi {status}", diff --git a/client/desktopApp/src/main/resources/localization/hi.json b/client/desktopApp/src/main/resources/localization/hi.json index 7654f45..d483015 100644 --- a/client/desktopApp/src/main/resources/localization/hi.json +++ b/client/desktopApp/src/main/resources/localization/hi.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "स्थानांतरण सफल! TX: {tx}", "wallet_trust_degraded": "हार्डवेयर ट्रस्ट कम हुआ", "wallet_warning": "चेतावनी", + "claim_node_no_signer": "किसी अन्य नोड का दावा करने से पहले आपका नोड शुरू होना चाहिए। अपना नोड शुरू करें, फिर दोबारा प्रयास करें।", "attestation_actions_desc": "इस रिकॉर्ड के लिए कार्रवाइयाँ", "chat_badge_message": "संदेश", "chat_details": "प्रकार {type} · दायरा {scope} · स्थिति {status}", diff --git a/client/desktopApp/src/main/resources/localization/id.json b/client/desktopApp/src/main/resources/localization/id.json index 809bf6a..aa8a9af 100644 --- a/client/desktopApp/src/main/resources/localization/id.json +++ b/client/desktopApp/src/main/resources/localization/id.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer berhasil! TX: {tx}", "wallet_trust_degraded": "Kepercayaan Perangkat Keras Menurun", "wallet_warning": "Peringatan", + "claim_node_no_signer": "Node Anda harus dijalankan terlebih dahulu sebelum dapat mengklaim node lain. Jalankan node Anda, lalu coba lagi.", "attestation_actions_desc": "Tindakan untuk catatan ini", "chat_badge_message": "PESAN", "chat_details": "jenis {type} · lingkup {scope} · status {status}", diff --git a/client/desktopApp/src/main/resources/localization/it.json b/client/desktopApp/src/main/resources/localization/it.json index 1748af9..38dd3e2 100644 --- a/client/desktopApp/src/main/resources/localization/it.json +++ b/client/desktopApp/src/main/resources/localization/it.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Trasferimento riuscito! TX: {tx}", "wallet_trust_degraded": "Fiducia Hardware Degradata", "wallet_warning": "Avviso", + "claim_node_no_signer": "Il tuo nodo deve essere avviato prima di poter rivendicare un altro nodo. Avvia il tuo nodo, quindi riprova.", "attestation_actions_desc": "Azioni per questo record", "chat_badge_message": "MESSAGGIO", "chat_details": "tipo {type} · ambito {scope} · stato {status}", diff --git a/client/desktopApp/src/main/resources/localization/ja.json b/client/desktopApp/src/main/resources/localization/ja.json index 07f3851..1dfa258 100644 --- a/client/desktopApp/src/main/resources/localization/ja.json +++ b/client/desktopApp/src/main/resources/localization/ja.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "送金成功!TX: {tx}", "wallet_trust_degraded": "ハードウェア信頼が低下しました", "wallet_warning": "警告", + "claim_node_no_signer": "他のノードの所有権を取得するには、まずお客様のノードを起動する必要があります。ノードを起動してから、もう一度お試しください。", "attestation_actions_desc": "このレコードに対する操作", "chat_badge_message": "メッセージ", "chat_details": "種別 {type} · スコープ {scope} · 状態 {status}", diff --git a/client/desktopApp/src/main/resources/localization/ko.json b/client/desktopApp/src/main/resources/localization/ko.json index a766a42..ac48476 100644 --- a/client/desktopApp/src/main/resources/localization/ko.json +++ b/client/desktopApp/src/main/resources/localization/ko.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "전송 성공! TX: {tx}", "wallet_trust_degraded": "하드웨어 신뢰 저하됨", "wallet_warning": "경고", + "claim_node_no_signer": "노드를 시작해야 다른 노드의 소유권을 주장할 수 있습니다. 노드를 시작한 후 다시 시도하세요.", "attestation_actions_desc": "이 레코드에 대한 작업", "chat_badge_message": "메시지", "chat_details": "유형 {type} · 범위 {scope} · 상태 {status}", diff --git a/client/desktopApp/src/main/resources/localization/mr.json b/client/desktopApp/src/main/resources/localization/mr.json index 0c7cf1f..2fa2e8f 100644 --- a/client/desktopApp/src/main/resources/localization/mr.json +++ b/client/desktopApp/src/main/resources/localization/mr.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer यशful! TX: {tx}", "wallet_trust_degraded": "हार्डवेअर विश्वास खालावला", "wallet_warning": "इशारा", + "claim_node_no_signer": "आणखी एका नोडचा दावा करण्यापूर्वी तुमचा नोड सुरू असणे आवश्यक आहे. तुमचा नोड सुरू करा आणि नंतर पुन्हा प्रयत्न करा.", "attestation_actions_desc": "या नोंदीसाठी क्रिया", "chat_badge_message": "संदेश", "chat_details": "प्रकार {type} · व्याप्ती {scope} · स्थिती {status}", diff --git a/client/desktopApp/src/main/resources/localization/my.json b/client/desktopApp/src/main/resources/localization/my.json index aa01418..72cc1a3 100644 --- a/client/desktopApp/src/main/resources/localization/my.json +++ b/client/desktopApp/src/main/resources/localization/my.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer အောင်မြင်ful! TX: {tx}", "wallet_trust_degraded": "Hardware ယုံကြည်မှု အားနည်းသွားပြီ", "wallet_warning": "သတိပေး", + "claim_node_no_signer": "အခြား Node ကို ပိုင်ဆိုင်မှုပြောရန် သင်၏ node ကို အရင်စတင်ထားရမည်။ သင်၏ node ကို စတင်ပြီး ထပ်မံကြိုးစားပါ။", "attestation_actions_desc": "ဤမှတ်တမ်းအတွက် လုပ်ဆောင်ချက်များ", "chat_badge_message": "မက်ဆေ့ချ်", "chat_details": "အမျိုးအစား {type} · scope {scope} · အခြေအနေ {status}", diff --git a/client/desktopApp/src/main/resources/localization/pa.json b/client/desktopApp/src/main/resources/localization/pa.json index ebf8145..da6082d 100644 --- a/client/desktopApp/src/main/resources/localization/pa.json +++ b/client/desktopApp/src/main/resources/localization/pa.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "ਟ੍ਰਾਂਸਫਰ ਸਫਲ! TX: {tx}", "wallet_trust_degraded": "ਹਾਰਡਵੇਅਰ ਭਰੋਸਾ ਘਟਿਆ", "wallet_warning": "ਚੇਤਾਵਨੀ", + "claim_node_no_signer": "ਕਿਸੇ ਹੋਰ ਨੋਡ ਦਾ ਦਾਅਵਾ ਕਰਨ ਤੋਂ ਪਹਿਲਾਂ ਤੁਹਾਡਾ ਨੋਡ ਸ਼ੁਰੂ ਹੋਣਾ ਲਾਜ਼ਮੀ ਹੈ। ਆਪਣਾ ਨੋਡ ਸ਼ੁਰੂ ਕਰੋ, ਫਿਰ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।", "attestation_actions_desc": "ਇਸ ਰਿਕਾਰਡ ਲਈ ਕਾਰਵਾਈਆਂ", "chat_badge_message": "ਸੁਨੇਹਾ", "chat_details": "ਕਿਸਮ {type} · ਦਾਇਰਾ {scope} · ਸਥਿਤੀ {status}", diff --git a/client/desktopApp/src/main/resources/localization/pt.json b/client/desktopApp/src/main/resources/localization/pt.json index fa5fa8c..d00c840 100644 --- a/client/desktopApp/src/main/resources/localization/pt.json +++ b/client/desktopApp/src/main/resources/localization/pt.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transferência bem-sucedida! TX: {tx}", "wallet_trust_degraded": "Confiança de Hardware Degradada", "wallet_warning": "Aviso", + "claim_node_no_signer": "Seu nó precisa ser iniciado antes que possa reivindicar outro nó. Inicie seu nó e tente novamente.", "attestation_actions_desc": "Ações para este registo", "chat_badge_message": "MENSAGEM", "chat_details": "tipo {type} · âmbito {scope} · estado {status}", diff --git a/client/desktopApp/src/main/resources/localization/ru.json b/client/desktopApp/src/main/resources/localization/ru.json index 484e28e..b4bbaeb 100644 --- a/client/desktopApp/src/main/resources/localization/ru.json +++ b/client/desktopApp/src/main/resources/localization/ru.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Перевод успешен! TX: {tx}", "wallet_trust_degraded": "Доверие к оборудованию ухудшено", "wallet_warning": "Предупреждение", + "claim_node_no_signer": "Ваш узел должен быть запущен, прежде чем он сможет заявить права на другой узел. Запустите узел и повторите попытку.", "attestation_actions_desc": "Действия для этой записи", "chat_badge_message": "СООБЩЕНИЕ", "chat_details": "тип {type} · область {scope} · статус {status}", diff --git a/client/desktopApp/src/main/resources/localization/sw.json b/client/desktopApp/src/main/resources/localization/sw.json index 522bfde..f05a8e1 100644 --- a/client/desktopApp/src/main/resources/localization/sw.json +++ b/client/desktopApp/src/main/resources/localization/sw.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Uhamisho umefanikiwa! TX: {tx}", "wallet_trust_degraded": "Kuaminika kwa Maunzi Kumedhoofika", "wallet_warning": "Onyo", + "claim_node_no_signer": "Nodi yako inahitaji kuanzishwa kabla ya kudai nodi nyingine. Anzisha nodi yako, kisha ujaribu tena.", "attestation_actions_desc": "Vitendo vya rekodi hii", "chat_badge_message": "UJUMBE", "chat_details": "aina {type} · wigo {scope} · hali {status}", diff --git a/client/desktopApp/src/main/resources/localization/ta.json b/client/desktopApp/src/main/resources/localization/ta.json index 34f5c0e..12dc310 100644 --- a/client/desktopApp/src/main/resources/localization/ta.json +++ b/client/desktopApp/src/main/resources/localization/ta.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "பரிமாற்றம் வெற்றிகரம்! TX: {tx}", "wallet_trust_degraded": "Hardware நம்பகத்தன்மை Degraded", "wallet_warning": "எச்சரிக்கை", + "claim_node_no_signer": "உங்கள் நோடு மற்றொரு நோடைக் கோரும் முன் தொடங்கப்பட்டிருக்க வேண்டும். உங்கள் நோடைத் தொடங்கி, மீண்டும் முயற்சிக்கவும்.", "attestation_actions_desc": "இந்தப் பதிவுக்கான செயல்கள்", "chat_badge_message": "செய்தி", "chat_details": "வகை {type} · எல்லை {scope} · நிலை {status}", diff --git a/client/desktopApp/src/main/resources/localization/te.json b/client/desktopApp/src/main/resources/localization/te.json index 78b6759..6a6cd1b 100644 --- a/client/desktopApp/src/main/resources/localization/te.json +++ b/client/desktopApp/src/main/resources/localization/te.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "బదిలీ విజయవంతమైంది! TX: {tx}", "wallet_trust_degraded": "హార్డ్‌వేర్ నమ్మకం తగ్గింది", "wallet_warning": "హెచ్చరిక", + "claim_node_no_signer": "మీ నోడ్ మరో నోడ్‌ను క్లెయిమ్ చేయడానికి ముందు ప్రారంభమై ఉండాలి. మీ నోడ్‌ను ప్రారంభించి, మళ్ళీ ప్రయత్నించండి.", "attestation_actions_desc": "ఈ రికార్డుకు చర్యలు", "chat_badge_message": "సందేశం", "chat_details": "రకం {type} · పరిధి {scope} · స్థితి {status}", diff --git a/client/desktopApp/src/main/resources/localization/th.json b/client/desktopApp/src/main/resources/localization/th.json index 78de09e..4cbc211 100644 --- a/client/desktopApp/src/main/resources/localization/th.json +++ b/client/desktopApp/src/main/resources/localization/th.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer สำเร็จful! TX: {tx}", "wallet_trust_degraded": "ความน่าเชื่อถือของฮาร์ดแวร์ลดลง", "wallet_warning": "คำเตือน", + "claim_node_no_signer": "โหนดของคุณต้องเริ่มทำงานก่อนจึงจะสามารถอ้างสิทธิ์โหนดอื่นได้ กรุณาเริ่มโหนดของคุณ แล้วลองอีกครั้ง", "attestation_actions_desc": "การดำเนินการสำหรับระเบียนนี้", "chat_badge_message": "ข้อความ", "chat_details": "ประเภท {type} · ขอบเขต {scope} · สถานะ {status}", diff --git a/client/desktopApp/src/main/resources/localization/tr.json b/client/desktopApp/src/main/resources/localization/tr.json index 9708b87..443adfc 100644 --- a/client/desktopApp/src/main/resources/localization/tr.json +++ b/client/desktopApp/src/main/resources/localization/tr.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer başarılı! TX: {tx}", "wallet_trust_degraded": "Donanım Güveni Düşmüş", "wallet_warning": "Uyarı", + "claim_node_no_signer": "Başka bir düğümü sahiplenmeden önce kendi düğümünüz başlatılmış olmalıdır. Düğümünüzü başlatın ve tekrar deneyin.", "attestation_actions_desc": "Bu kayıt için eylemler", "chat_badge_message": "MESAJ", "chat_details": "tür {type} · kapsam {scope} · durum {status}", diff --git a/client/desktopApp/src/main/resources/localization/uk.json b/client/desktopApp/src/main/resources/localization/uk.json index fafcb63..a3ebdd0 100644 --- a/client/desktopApp/src/main/resources/localization/uk.json +++ b/client/desktopApp/src/main/resources/localization/uk.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Переказ успішний! TX: {tx}", "wallet_trust_degraded": "Апаратну довіру знижено", "wallet_warning": "Попередження", + "claim_node_no_signer": "Ваш вузол потрібно запустити, перш ніж він зможе заявити право на інший вузол. Запустіть свій вузол і спробуйте ще раз.", "attestation_actions_desc": "Дії для цього запису", "chat_badge_message": "ПОВІДОМЛЕННЯ", "chat_details": "тип {type} · область {scope} · статус {status}", diff --git a/client/desktopApp/src/main/resources/localization/ur.json b/client/desktopApp/src/main/resources/localization/ur.json index 7c17d31..39b48e6 100644 --- a/client/desktopApp/src/main/resources/localization/ur.json +++ b/client/desktopApp/src/main/resources/localization/ur.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "ٹرانسفر کامیاب! TX: {tx}", "wallet_trust_degraded": "ہارڈ ویئر اعتماد کمزور پڑ گیا", "wallet_warning": "انتباہ", + "claim_node_no_signer": "کسی اور نوڈ کا دعویٰ کرنے سے پہلے آپ کا نوڈ شروع ہونا ضروری ہے۔ اپنا نوڈ شروع کریں، پھر دوبارہ کوشش کریں۔", "attestation_actions_desc": "اس ریکارڈ کے لیے اقدامات", "chat_badge_message": "پیغام", "chat_details": "قسم {type} · دائرہ {scope} · حیثیت {status}", diff --git a/client/desktopApp/src/main/resources/localization/vi.json b/client/desktopApp/src/main/resources/localization/vi.json index 245869a..018b388 100644 --- a/client/desktopApp/src/main/resources/localization/vi.json +++ b/client/desktopApp/src/main/resources/localization/vi.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Chuyển khoản thành công! TX: {tx}", "wallet_trust_degraded": "Tin tưởng Suy giảm", "wallet_warning": "Cảnh báo", + "claim_node_no_signer": "Nút của bạn phải được khởi động trước khi có thể nhận quyền một nút khác. Hãy khởi động nút của bạn, rồi thử lại.", "attestation_actions_desc": "Các thao tác cho bản ghi này", "chat_badge_message": "TIN NHẮN", "chat_details": "loại {type} · phạm vi {scope} · trạng thái {status}", diff --git a/client/desktopApp/src/main/resources/localization/yo.json b/client/desktopApp/src/main/resources/localization/yo.json index d7f8400..0ebc93f 100644 --- a/client/desktopApp/src/main/resources/localization/yo.json +++ b/client/desktopApp/src/main/resources/localization/yo.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Gbígbé ṣe àṣeyọrí! TX: {tx}", "wallet_trust_degraded": "Ìgbẹ́kẹ̀lé Ohun Ẹ̀rọ Ti Dín Kù", "wallet_warning": "Ìkìlọ̀", + "claim_node_no_signer": "Gbọ́dọ̀ bẹ̀rẹ̀ node rẹ kí ó tó lè gba node mìíràn. Bẹ̀rẹ̀ node rẹ, lẹ́yìn náà gbìyànjú lẹ́ẹ̀kan sí i.", "attestation_actions_desc": "Àwọn ìgbésẹ̀ fún àkọsílẹ̀ yìí", "chat_badge_message": "ÌFIRÁNṢẸ́", "chat_details": "irú {type} · ààlà {scope} · ipò {status}", diff --git a/client/desktopApp/src/main/resources/localization/zh.json b/client/desktopApp/src/main/resources/localization/zh.json index 2bcede3..060d438 100644 --- a/client/desktopApp/src/main/resources/localization/zh.json +++ b/client/desktopApp/src/main/resources/localization/zh.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "转账成功!TX: {tx}", "wallet_trust_degraded": "硬件信任降级", "wallet_warning": "警告", + "claim_node_no_signer": "您的节点必须先启动才能认领另一个节点。请启动您的节点,然后重试。", "attestation_actions_desc": "此记录的操作", "chat_badge_message": "消息", "chat_details": "类型 {type} · 范围 {scope} · 状态 {status}", diff --git a/client/iosApp/iosApp/localization/am.json b/client/iosApp/iosApp/localization/am.json index b478e20..55d8b26 100644 --- a/client/iosApp/iosApp/localization/am.json +++ b/client/iosApp/iosApp/localization/am.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "ማስተላለፍ ተሳክቷል! TX: {tx}", "wallet_trust_degraded": "ስልክ ሥቅ ተዳክሞ ነበር", "wallet_warning": "ማስጠንቀቂያ", + "claim_node_no_signer": "የእርስዎ ኖድ ሌላ ኖድ ከመያዙ በፊት መጀመር አለበት። ኖድዎን ይጀምሩ፣ ከዚያ እንደገና ይሞክሩ።", "attestation_actions_desc": "ለዚህ መዝገብ ያሉ እርምጃዎች", "chat_badge_message": "መልዕክት", "chat_details": "ዓይነት {type} · ወሰን {scope} · ሁኔታ {status}", diff --git a/client/iosApp/iosApp/localization/ar.json b/client/iosApp/iosApp/localization/ar.json index 3b025d4..31d7e78 100644 --- a/client/iosApp/iosApp/localization/ar.json +++ b/client/iosApp/iosApp/localization/ar.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "نجح التحويل! TX: {tx}", "wallet_trust_degraded": "تدهورت ثقة الأجهزة", "wallet_warning": "تحذير", + "claim_node_no_signer": "يجب تشغيل عقدتك قبل أن تتمكن من المطالبة بعقدة أخرى. شغّل عقدتك، ثم حاول مرة أخرى.", "attestation_actions_desc": "إجراءات هذا السجل", "chat_badge_message": "رسالة", "chat_details": "النوع {type} · النطاق {scope} · الحالة {status}", diff --git a/client/iosApp/iosApp/localization/bn.json b/client/iosApp/iosApp/localization/bn.json index 30f1052..744a4b8 100644 --- a/client/iosApp/iosApp/localization/bn.json +++ b/client/iosApp/iosApp/localization/bn.json @@ -2925,6 +2925,7 @@ "wallet_transfer_success": "ট্রান্সফার সফল! TX: {tx}", "wallet_trust_degraded": "হার্ডওয়্যার বিশ্বাস হ্রাস পেয়েছে", "wallet_warning": "সতর্কতা", + "claim_node_no_signer": "আরেকটি নোড দাবি করার আগে আপনার নোড চালু থাকতে হবে। আপনার নোড চালু করুন, তারপর আবার চেষ্টা করুন।", "attestation_actions_desc": "এই রেকর্ডের জন্য পদক্ষেপ", "chat_badge_message": "বার্তা", "chat_details": "ধরন {type} · পরিসর {scope} · অবস্থা {status}", diff --git a/client/iosApp/iosApp/localization/de.json b/client/iosApp/iosApp/localization/de.json index 74e684c..1bb19cb 100644 --- a/client/iosApp/iosApp/localization/de.json +++ b/client/iosApp/iosApp/localization/de.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Überweisung erfolgreich! TX: {tx}", "wallet_trust_degraded": "Hardware-Vertrauen herabgestuft", "wallet_warning": "Warnung", + "claim_node_no_signer": "Ihr Node muss gestartet sein, bevor er einen anderen Knoten übernehmen kann. Starten Sie Ihren Node und versuchen Sie es erneut.", "attestation_actions_desc": "Aktionen für diesen Datensatz", "chat_badge_message": "NACHRICHT", "chat_details": "Typ {type} · Bereich {scope} · Status {status}", diff --git a/client/iosApp/iosApp/localization/en.json b/client/iosApp/iosApp/localization/en.json index 8561878..336913c 100644 --- a/client/iosApp/iosApp/localization/en.json +++ b/client/iosApp/iosApp/localization/en.json @@ -2993,7 +2993,8 @@ "wallet_transfer_note": "Transfer to another Base address for fiat cashout", "wallet_transfer_success": "Transfer successful! TX: {tx}", "wallet_trust_degraded": "Hardware Trust Degraded", - "wallet_warning": "Warning" + "wallet_warning": "Warning", + "claim_node_no_signer": "Your node must be started before it can claim another node. Start your node, then try again." }, "moderation": { "ladder": { diff --git a/client/iosApp/iosApp/localization/es.json b/client/iosApp/iosApp/localization/es.json index 66a6762..6b600c2 100644 --- a/client/iosApp/iosApp/localization/es.json +++ b/client/iosApp/iosApp/localization/es.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "¡Transferencia exitosa! TX: {tx}", "wallet_trust_degraded": "Confianza de Hardware Degradada", "wallet_warning": "Advertencia", + "claim_node_no_signer": "Tu nodo debe estar iniciado antes de poder reclamar otro nodo. Inicia tu nodo e inténtalo de nuevo.", "attestation_actions_desc": "Acciones para este registro", "chat_badge_message": "MENSAJE", "chat_details": "tipo {type} · ámbito {scope} · estado {status}", diff --git a/client/iosApp/iosApp/localization/fa.json b/client/iosApp/iosApp/localization/fa.json index d9caea0..0fda01b 100644 --- a/client/iosApp/iosApp/localization/fa.json +++ b/client/iosApp/iosApp/localization/fa.json @@ -2930,6 +2930,7 @@ "wallet_transfer_success": "انتقال با موفقیت انجام شد! TX: {tx}", "wallet_trust_degraded": "افت اعتماد سخت‌افزاری", "wallet_warning": "هشدار", + "claim_node_no_signer": "گرهٔ شما باید پیش از ادعای گره‌ای دیگر راه‌اندازی شده باشد. گرهٔ خود را راه‌اندازی کنید و دوباره تلاش کنید.", "attestation_actions_desc": "اقدامات برای این رکورد", "chat_badge_message": "پیام", "chat_details": "نوع {type} · محدوده {scope} · وضعیت {status}", diff --git a/client/iosApp/iosApp/localization/fr.json b/client/iosApp/iosApp/localization/fr.json index 10abf12..5716835 100644 --- a/client/iosApp/iosApp/localization/fr.json +++ b/client/iosApp/iosApp/localization/fr.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfert réussi ! TX: {tx}", "wallet_trust_degraded": "Confiance Matérielle Dégradée", "wallet_warning": "Avertissement", + "claim_node_no_signer": "Votre nœud doit être démarré avant de pouvoir revendiquer un autre nœud. Démarrez votre nœud, puis réessayez.", "attestation_actions_desc": "Actions pour cet enregistrement", "chat_badge_message": "MESSAGE", "chat_details": "type {type} · portée {scope} · statut {status}", diff --git a/client/iosApp/iosApp/localization/ha.json b/client/iosApp/iosApp/localization/ha.json index 7ac4491..79baae8 100644 --- a/client/iosApp/iosApp/localization/ha.json +++ b/client/iosApp/iosApp/localization/ha.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Aika ya yi nasara! TX: {tx}", "wallet_trust_degraded": "Tabbas Kayan Aiki Ya Rage", "wallet_warning": "Gargaɗi", + "claim_node_no_signer": "Dole ne a fara kumburinka kafin ya iya karɓar wani kumburi. Fara kumburinka, sannan ka sake gwadawa.", "attestation_actions_desc": "Ayyuka don wannan rikodin", "chat_badge_message": "SAƘO", "chat_details": "nau'i {type} · iyaka {scope} · matsayi {status}", diff --git a/client/iosApp/iosApp/localization/hi.json b/client/iosApp/iosApp/localization/hi.json index 7654f45..d483015 100644 --- a/client/iosApp/iosApp/localization/hi.json +++ b/client/iosApp/iosApp/localization/hi.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "स्थानांतरण सफल! TX: {tx}", "wallet_trust_degraded": "हार्डवेयर ट्रस्ट कम हुआ", "wallet_warning": "चेतावनी", + "claim_node_no_signer": "किसी अन्य नोड का दावा करने से पहले आपका नोड शुरू होना चाहिए। अपना नोड शुरू करें, फिर दोबारा प्रयास करें।", "attestation_actions_desc": "इस रिकॉर्ड के लिए कार्रवाइयाँ", "chat_badge_message": "संदेश", "chat_details": "प्रकार {type} · दायरा {scope} · स्थिति {status}", diff --git a/client/iosApp/iosApp/localization/id.json b/client/iosApp/iosApp/localization/id.json index 809bf6a..aa8a9af 100644 --- a/client/iosApp/iosApp/localization/id.json +++ b/client/iosApp/iosApp/localization/id.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer berhasil! TX: {tx}", "wallet_trust_degraded": "Kepercayaan Perangkat Keras Menurun", "wallet_warning": "Peringatan", + "claim_node_no_signer": "Node Anda harus dijalankan terlebih dahulu sebelum dapat mengklaim node lain. Jalankan node Anda, lalu coba lagi.", "attestation_actions_desc": "Tindakan untuk catatan ini", "chat_badge_message": "PESAN", "chat_details": "jenis {type} · lingkup {scope} · status {status}", diff --git a/client/iosApp/iosApp/localization/it.json b/client/iosApp/iosApp/localization/it.json index 1748af9..38dd3e2 100644 --- a/client/iosApp/iosApp/localization/it.json +++ b/client/iosApp/iosApp/localization/it.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Trasferimento riuscito! TX: {tx}", "wallet_trust_degraded": "Fiducia Hardware Degradata", "wallet_warning": "Avviso", + "claim_node_no_signer": "Il tuo nodo deve essere avviato prima di poter rivendicare un altro nodo. Avvia il tuo nodo, quindi riprova.", "attestation_actions_desc": "Azioni per questo record", "chat_badge_message": "MESSAGGIO", "chat_details": "tipo {type} · ambito {scope} · stato {status}", diff --git a/client/iosApp/iosApp/localization/ja.json b/client/iosApp/iosApp/localization/ja.json index 07f3851..1dfa258 100644 --- a/client/iosApp/iosApp/localization/ja.json +++ b/client/iosApp/iosApp/localization/ja.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "送金成功!TX: {tx}", "wallet_trust_degraded": "ハードウェア信頼が低下しました", "wallet_warning": "警告", + "claim_node_no_signer": "他のノードの所有権を取得するには、まずお客様のノードを起動する必要があります。ノードを起動してから、もう一度お試しください。", "attestation_actions_desc": "このレコードに対する操作", "chat_badge_message": "メッセージ", "chat_details": "種別 {type} · スコープ {scope} · 状態 {status}", diff --git a/client/iosApp/iosApp/localization/ko.json b/client/iosApp/iosApp/localization/ko.json index a766a42..ac48476 100644 --- a/client/iosApp/iosApp/localization/ko.json +++ b/client/iosApp/iosApp/localization/ko.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "전송 성공! TX: {tx}", "wallet_trust_degraded": "하드웨어 신뢰 저하됨", "wallet_warning": "경고", + "claim_node_no_signer": "노드를 시작해야 다른 노드의 소유권을 주장할 수 있습니다. 노드를 시작한 후 다시 시도하세요.", "attestation_actions_desc": "이 레코드에 대한 작업", "chat_badge_message": "메시지", "chat_details": "유형 {type} · 범위 {scope} · 상태 {status}", diff --git a/client/iosApp/iosApp/localization/mr.json b/client/iosApp/iosApp/localization/mr.json index 0c7cf1f..2fa2e8f 100644 --- a/client/iosApp/iosApp/localization/mr.json +++ b/client/iosApp/iosApp/localization/mr.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer यशful! TX: {tx}", "wallet_trust_degraded": "हार्डवेअर विश्वास खालावला", "wallet_warning": "इशारा", + "claim_node_no_signer": "आणखी एका नोडचा दावा करण्यापूर्वी तुमचा नोड सुरू असणे आवश्यक आहे. तुमचा नोड सुरू करा आणि नंतर पुन्हा प्रयत्न करा.", "attestation_actions_desc": "या नोंदीसाठी क्रिया", "chat_badge_message": "संदेश", "chat_details": "प्रकार {type} · व्याप्ती {scope} · स्थिती {status}", diff --git a/client/iosApp/iosApp/localization/my.json b/client/iosApp/iosApp/localization/my.json index aa01418..72cc1a3 100644 --- a/client/iosApp/iosApp/localization/my.json +++ b/client/iosApp/iosApp/localization/my.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer အောင်မြင်ful! TX: {tx}", "wallet_trust_degraded": "Hardware ယုံကြည်မှု အားနည်းသွားပြီ", "wallet_warning": "သတိပေး", + "claim_node_no_signer": "အခြား Node ကို ပိုင်ဆိုင်မှုပြောရန် သင်၏ node ကို အရင်စတင်ထားရမည်။ သင်၏ node ကို စတင်ပြီး ထပ်မံကြိုးစားပါ။", "attestation_actions_desc": "ဤမှတ်တမ်းအတွက် လုပ်ဆောင်ချက်များ", "chat_badge_message": "မက်ဆေ့ချ်", "chat_details": "အမျိုးအစား {type} · scope {scope} · အခြေအနေ {status}", diff --git a/client/iosApp/iosApp/localization/pa.json b/client/iosApp/iosApp/localization/pa.json index ebf8145..da6082d 100644 --- a/client/iosApp/iosApp/localization/pa.json +++ b/client/iosApp/iosApp/localization/pa.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "ਟ੍ਰਾਂਸਫਰ ਸਫਲ! TX: {tx}", "wallet_trust_degraded": "ਹਾਰਡਵੇਅਰ ਭਰੋਸਾ ਘਟਿਆ", "wallet_warning": "ਚੇਤਾਵਨੀ", + "claim_node_no_signer": "ਕਿਸੇ ਹੋਰ ਨੋਡ ਦਾ ਦਾਅਵਾ ਕਰਨ ਤੋਂ ਪਹਿਲਾਂ ਤੁਹਾਡਾ ਨੋਡ ਸ਼ੁਰੂ ਹੋਣਾ ਲਾਜ਼ਮੀ ਹੈ। ਆਪਣਾ ਨੋਡ ਸ਼ੁਰੂ ਕਰੋ, ਫਿਰ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।", "attestation_actions_desc": "ਇਸ ਰਿਕਾਰਡ ਲਈ ਕਾਰਵਾਈਆਂ", "chat_badge_message": "ਸੁਨੇਹਾ", "chat_details": "ਕਿਸਮ {type} · ਦਾਇਰਾ {scope} · ਸਥਿਤੀ {status}", diff --git a/client/iosApp/iosApp/localization/pt.json b/client/iosApp/iosApp/localization/pt.json index fa5fa8c..d00c840 100644 --- a/client/iosApp/iosApp/localization/pt.json +++ b/client/iosApp/iosApp/localization/pt.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transferência bem-sucedida! TX: {tx}", "wallet_trust_degraded": "Confiança de Hardware Degradada", "wallet_warning": "Aviso", + "claim_node_no_signer": "Seu nó precisa ser iniciado antes que possa reivindicar outro nó. Inicie seu nó e tente novamente.", "attestation_actions_desc": "Ações para este registo", "chat_badge_message": "MENSAGEM", "chat_details": "tipo {type} · âmbito {scope} · estado {status}", diff --git a/client/iosApp/iosApp/localization/ru.json b/client/iosApp/iosApp/localization/ru.json index 484e28e..b4bbaeb 100644 --- a/client/iosApp/iosApp/localization/ru.json +++ b/client/iosApp/iosApp/localization/ru.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Перевод успешен! TX: {tx}", "wallet_trust_degraded": "Доверие к оборудованию ухудшено", "wallet_warning": "Предупреждение", + "claim_node_no_signer": "Ваш узел должен быть запущен, прежде чем он сможет заявить права на другой узел. Запустите узел и повторите попытку.", "attestation_actions_desc": "Действия для этой записи", "chat_badge_message": "СООБЩЕНИЕ", "chat_details": "тип {type} · область {scope} · статус {status}", diff --git a/client/iosApp/iosApp/localization/sw.json b/client/iosApp/iosApp/localization/sw.json index 522bfde..f05a8e1 100644 --- a/client/iosApp/iosApp/localization/sw.json +++ b/client/iosApp/iosApp/localization/sw.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Uhamisho umefanikiwa! TX: {tx}", "wallet_trust_degraded": "Kuaminika kwa Maunzi Kumedhoofika", "wallet_warning": "Onyo", + "claim_node_no_signer": "Nodi yako inahitaji kuanzishwa kabla ya kudai nodi nyingine. Anzisha nodi yako, kisha ujaribu tena.", "attestation_actions_desc": "Vitendo vya rekodi hii", "chat_badge_message": "UJUMBE", "chat_details": "aina {type} · wigo {scope} · hali {status}", diff --git a/client/iosApp/iosApp/localization/ta.json b/client/iosApp/iosApp/localization/ta.json index 34f5c0e..12dc310 100644 --- a/client/iosApp/iosApp/localization/ta.json +++ b/client/iosApp/iosApp/localization/ta.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "பரிமாற்றம் வெற்றிகரம்! TX: {tx}", "wallet_trust_degraded": "Hardware நம்பகத்தன்மை Degraded", "wallet_warning": "எச்சரிக்கை", + "claim_node_no_signer": "உங்கள் நோடு மற்றொரு நோடைக் கோரும் முன் தொடங்கப்பட்டிருக்க வேண்டும். உங்கள் நோடைத் தொடங்கி, மீண்டும் முயற்சிக்கவும்.", "attestation_actions_desc": "இந்தப் பதிவுக்கான செயல்கள்", "chat_badge_message": "செய்தி", "chat_details": "வகை {type} · எல்லை {scope} · நிலை {status}", diff --git a/client/iosApp/iosApp/localization/te.json b/client/iosApp/iosApp/localization/te.json index 78b6759..6a6cd1b 100644 --- a/client/iosApp/iosApp/localization/te.json +++ b/client/iosApp/iosApp/localization/te.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "బదిలీ విజయవంతమైంది! TX: {tx}", "wallet_trust_degraded": "హార్డ్‌వేర్ నమ్మకం తగ్గింది", "wallet_warning": "హెచ్చరిక", + "claim_node_no_signer": "మీ నోడ్ మరో నోడ్‌ను క్లెయిమ్ చేయడానికి ముందు ప్రారంభమై ఉండాలి. మీ నోడ్‌ను ప్రారంభించి, మళ్ళీ ప్రయత్నించండి.", "attestation_actions_desc": "ఈ రికార్డుకు చర్యలు", "chat_badge_message": "సందేశం", "chat_details": "రకం {type} · పరిధి {scope} · స్థితి {status}", diff --git a/client/iosApp/iosApp/localization/th.json b/client/iosApp/iosApp/localization/th.json index 78de09e..4cbc211 100644 --- a/client/iosApp/iosApp/localization/th.json +++ b/client/iosApp/iosApp/localization/th.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer สำเร็จful! TX: {tx}", "wallet_trust_degraded": "ความน่าเชื่อถือของฮาร์ดแวร์ลดลง", "wallet_warning": "คำเตือน", + "claim_node_no_signer": "โหนดของคุณต้องเริ่มทำงานก่อนจึงจะสามารถอ้างสิทธิ์โหนดอื่นได้ กรุณาเริ่มโหนดของคุณ แล้วลองอีกครั้ง", "attestation_actions_desc": "การดำเนินการสำหรับระเบียนนี้", "chat_badge_message": "ข้อความ", "chat_details": "ประเภท {type} · ขอบเขต {scope} · สถานะ {status}", diff --git a/client/iosApp/iosApp/localization/tr.json b/client/iosApp/iosApp/localization/tr.json index 9708b87..443adfc 100644 --- a/client/iosApp/iosApp/localization/tr.json +++ b/client/iosApp/iosApp/localization/tr.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer başarılı! TX: {tx}", "wallet_trust_degraded": "Donanım Güveni Düşmüş", "wallet_warning": "Uyarı", + "claim_node_no_signer": "Başka bir düğümü sahiplenmeden önce kendi düğümünüz başlatılmış olmalıdır. Düğümünüzü başlatın ve tekrar deneyin.", "attestation_actions_desc": "Bu kayıt için eylemler", "chat_badge_message": "MESAJ", "chat_details": "tür {type} · kapsam {scope} · durum {status}", diff --git a/client/iosApp/iosApp/localization/uk.json b/client/iosApp/iosApp/localization/uk.json index fafcb63..a3ebdd0 100644 --- a/client/iosApp/iosApp/localization/uk.json +++ b/client/iosApp/iosApp/localization/uk.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Переказ успішний! TX: {tx}", "wallet_trust_degraded": "Апаратну довіру знижено", "wallet_warning": "Попередження", + "claim_node_no_signer": "Ваш вузол потрібно запустити, перш ніж він зможе заявити право на інший вузол. Запустіть свій вузол і спробуйте ще раз.", "attestation_actions_desc": "Дії для цього запису", "chat_badge_message": "ПОВІДОМЛЕННЯ", "chat_details": "тип {type} · область {scope} · статус {status}", diff --git a/client/iosApp/iosApp/localization/ur.json b/client/iosApp/iosApp/localization/ur.json index 7c17d31..39b48e6 100644 --- a/client/iosApp/iosApp/localization/ur.json +++ b/client/iosApp/iosApp/localization/ur.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "ٹرانسفر کامیاب! TX: {tx}", "wallet_trust_degraded": "ہارڈ ویئر اعتماد کمزور پڑ گیا", "wallet_warning": "انتباہ", + "claim_node_no_signer": "کسی اور نوڈ کا دعویٰ کرنے سے پہلے آپ کا نوڈ شروع ہونا ضروری ہے۔ اپنا نوڈ شروع کریں، پھر دوبارہ کوشش کریں۔", "attestation_actions_desc": "اس ریکارڈ کے لیے اقدامات", "chat_badge_message": "پیغام", "chat_details": "قسم {type} · دائرہ {scope} · حیثیت {status}", diff --git a/client/iosApp/iosApp/localization/vi.json b/client/iosApp/iosApp/localization/vi.json index 245869a..018b388 100644 --- a/client/iosApp/iosApp/localization/vi.json +++ b/client/iosApp/iosApp/localization/vi.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Chuyển khoản thành công! TX: {tx}", "wallet_trust_degraded": "Tin tưởng Suy giảm", "wallet_warning": "Cảnh báo", + "claim_node_no_signer": "Nút của bạn phải được khởi động trước khi có thể nhận quyền một nút khác. Hãy khởi động nút của bạn, rồi thử lại.", "attestation_actions_desc": "Các thao tác cho bản ghi này", "chat_badge_message": "TIN NHẮN", "chat_details": "loại {type} · phạm vi {scope} · trạng thái {status}", diff --git a/client/iosApp/iosApp/localization/yo.json b/client/iosApp/iosApp/localization/yo.json index d7f8400..0ebc93f 100644 --- a/client/iosApp/iosApp/localization/yo.json +++ b/client/iosApp/iosApp/localization/yo.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Gbígbé ṣe àṣeyọrí! TX: {tx}", "wallet_trust_degraded": "Ìgbẹ́kẹ̀lé Ohun Ẹ̀rọ Ti Dín Kù", "wallet_warning": "Ìkìlọ̀", + "claim_node_no_signer": "Gbọ́dọ̀ bẹ̀rẹ̀ node rẹ kí ó tó lè gba node mìíràn. Bẹ̀rẹ̀ node rẹ, lẹ́yìn náà gbìyànjú lẹ́ẹ̀kan sí i.", "attestation_actions_desc": "Àwọn ìgbésẹ̀ fún àkọsílẹ̀ yìí", "chat_badge_message": "ÌFIRÁNṢẸ́", "chat_details": "irú {type} · ààlà {scope} · ipò {status}", diff --git a/client/iosApp/iosApp/localization/zh.json b/client/iosApp/iosApp/localization/zh.json index 2bcede3..060d438 100644 --- a/client/iosApp/iosApp/localization/zh.json +++ b/client/iosApp/iosApp/localization/zh.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "转账成功!TX: {tx}", "wallet_trust_degraded": "硬件信任降级", "wallet_warning": "警告", + "claim_node_no_signer": "您的节点必须先启动才能认领另一个节点。请启动您的节点,然后重试。", "attestation_actions_desc": "此记录的操作", "chat_badge_message": "消息", "chat_details": "类型 {type} · 范围 {scope} · 状态 {status}", diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt index b9b304c..88645c0 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt @@ -458,6 +458,19 @@ fun CIRISApp( TestAutomation.setCurrentScreen(currentScreen::class.simpleName ?: "unknown") } + // Publish the node-vs-agent gate to test automation. + // + // A walk-test cannot assert this from the element tree without asserting + // the layout instead of the gate -- it would pass a client that draws agent + // affordances against a bare node. `null` is published as "unset" rather + // than defaulted to NODE, because undetermined (a folded brain that is not + // answering) is a distinct state the client must retry out of, and a + // harness that cannot see it cannot catch a client that latches. + LaunchedEffect(clientMode, nodeBaseUrl) { + ai.ciris.mobile.shared.testing.TestAutomationState.clientMode = clientMode?.name ?: "unset" + ai.ciris.mobile.shared.testing.TestAutomationState.nodeUrl = nodeBaseUrl + } + // Handle system back button - navigate back to appropriate parent screen // homeTarget (the probed landing), not Screen.Interact: on the node client the landing surface is diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClaimFailure.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClaimFailure.kt new file mode 100644 index 0000000..6fd15a7 --- /dev/null +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClaimFailure.kt @@ -0,0 +1,95 @@ +package ai.ciris.mobile.shared.models + +/** + * WHY A CLAIM FAILED — as a fact, not a sentence. + * + * Claiming a remote node is the act that binds a responsible party to it, and + * it fails in ways that need DIFFERENT NEXT ACTIONS from the operator: + * + * - the PIN was wrong -> re-read it from the node's console + * - the node is already claimed -> nothing to do; it has an owner + * - the node could not be reached -> a network/address problem, not a secret + * - the NodeCode itself is malformed -> re-scan or re-paste the code + * + * Before this, all four rendered as one string ("Claim failed: …" with the + * node's raw message appended, or a PIN message matched on English prose), so + * an operator claiming a fleet of research agents could not tell "I mistyped + * eight characters" from "this one already has an owner" without reading a + * substrate error. Those are opposite situations: one is a retry, the other is + * a success that already happened. + * + * MATCHED ON THE SERVER'S STABLE CODES, NOT ITS PROSE. CIRISServer emits + * `auth.claim.pin_invalid`, `auth.claim.pin_missing` and `auth.claim.not_armed` + * (`src/auth/bootstrap.rs`), and those are wire-stable in a way the English + * sentences beside them are not — the previous prose match (`"claim pin"`, + * `"invalid pin"`) breaks the moment the server rewords, and rewording an error + * message is not a breaking change anybody would announce. The prose patterns + * are kept BELOW the codes as a fallback for older nodes, which is the only + * thing they are fit for. + */ +enum class ClaimFailure { + /** The one-time PIN was wrong, or was not supplied. */ + PIN_REJECTED, + + /** + * The node is not armed for a first-run claim — it has no one-time PIN + * because ownership is already established. Not a retryable error. + */ + ALREADY_CLAIMED, + + /** The target could not be reached: no transport hint, or the connection failed. */ + UNREACHABLE, + + /** The NodeCode could not be decoded. */ + BAD_NODE_CODE, + + /** Anything else — surfaced verbatim rather than guessed at. */ + UNKNOWN; + + /** Re-entering the PIN or retrying can plausibly succeed. */ + val isRetryable: Boolean + get() = this == PIN_REJECTED || this == UNREACHABLE || this == BAD_NODE_CODE +} + +/** + * Classify a claim failure from whatever the node said. + * + * Order matters: the codes are checked first and win outright. A message can + * satisfy more than one prose pattern (a target rejection body carries both the + * word "claim" and an HTTP status), so the fallbacks are ordered most- to + * least-specific and the first match is taken. + */ +fun classifyClaimFailure(message: String?): ClaimFailure { + val m = message?.lowercase().orEmpty() + if (m.isBlank()) return ClaimFailure.UNKNOWN + + // 1) The server's own codes. Wire-stable; these are the answer when present. + if (m.contains("auth.claim.pin_invalid") || m.contains("auth.claim.pin_missing")) { + return ClaimFailure.PIN_REJECTED + } + if (m.contains("auth.claim.not_armed")) return ClaimFailure.ALREADY_CLAIMED + + // 2) Prose, for nodes older than those codes. Never the primary signal. + // "not armed for a first-run claim" is the distinguishing phrase, and it + // must be tested BEFORE the PIN patterns: the sentence carrying it also + // contains the words "one-time PIN", so a PIN-first order sends the operator + // to the console to re-read a PIN that was never minted, on a node that + // already has an owner. Caught by this class's own test. + if (m.contains("not armed") || m.contains("already claimed") || + m.contains("ownership may already be claimed") + ) { + return ClaimFailure.ALREADY_CLAIMED + } + if (m.contains("claim_pin") || m.contains("claim pin") || m.contains("invalid pin")) { + return ClaimFailure.PIN_REJECTED + } + if (m.contains("no transport_hint") || m.contains("cannot reach the node") || + m.contains("reach target node") + ) { + return ClaimFailure.UNREACHABLE + } + if (m.contains("decode target nodecode") || m.contains("bad node code")) { + return ClaimFailure.BAD_NODE_CODE + } + return ClaimFailure.UNKNOWN +} diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationState.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationState.kt index fd876d7..76987c5 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationState.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationState.kt @@ -19,6 +19,18 @@ object TestAutomationState { var currentScreen: String = "unknown" var isEnabled: Boolean = false + /** + * The node-vs-agent gate, as the app has actually derived it. + * + * `"unset"` is meaningful: it is the state a folded-but-unreachable brain + * must leave the client in, pending retry. Written by `CIRISApp` wherever + * `clientMode` is assigned; read by the test server's `/state`. + */ + var clientMode: String = "unset" + + /** The node URL the app settled on -- local default, or a remote override. */ + var nodeUrl: String = "" + // Window position offset (desktop only, for converting to screen coords) var windowX: Int = 0 var windowY: Int = 0 diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestServerModels.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestServerModels.kt index 7b3de11..571d032 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestServerModels.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestServerModels.kt @@ -28,6 +28,29 @@ data class TreeResponse(val screen: String, val elements: List, val @Serializable data class ScreenResponse(val screen: String) +/** + * The app's own account of the gates a walk-test needs to assert. + * + * `/screen` and `/tree` can only tell a harness what is DRAWN, and the two + * things a client of a federation node must get right are not drawings: which + * node it is talking to, and whether that node is carrying a brain. Inferring + * the mode from which widgets happen to be on screen makes the assertion a + * restatement of the layout -- it goes green for a client that renders agent + * affordances against a bare node, which is the bug. + * + * [clientMode] is `ClientMode.name`, or `"unset"` while the probe is still + * undetermined -- which is a REAL state, not a missing value: a folded brain + * that is not answering must leave the gate unset and be retried, never latched + * (CIRISServer#390). A harness has to be able to see the difference. + */ +@Serializable +data class StateResponse( + val screen: String, + val testMode: Boolean, + val clientMode: String, + val nodeUrl: String +) + @Serializable data class ClickRequest(val testTag: String) diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ClaimNodeScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ClaimNodeScreen.kt index cdbb995..347d6d7 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ClaimNodeScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ClaimNodeScreen.kt @@ -27,6 +27,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold @@ -83,6 +85,14 @@ fun ClaimNodeScreen( // Default to "self" but make the founder pick before claiming. var cohortScope by remember { mutableStateOf("self") } + // ASK THE PRECONDITION FIRST. The claim is signed by this device's OWN node + // (the app does no crypto), so without it running there is nothing to type a + // NodeCode and a one-time PIN into. Checking on entry means the operator + // learns that before making a trip to the target node's console, rather than + // after -- see docs/FSD-remote-first-run-claim.md A2. + androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.checkLocalSigner() } + val signerMissing = bootstrap.localSignerReady == false + // While the connect-or-claim pipeline is running we disable the button. val inFlight = bootstrap.inProgress || bootstrap.claimInProgress val pinned = bootstrap.pinnedProfile @@ -128,6 +138,23 @@ fun ClaimNodeScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (signerMissing) { + Spacer(Modifier.height(16.dp)) + Card( + modifier = Modifier.fillMaxWidth().testable("card_claim_no_signer"), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Text( + text = localizedString("mobile.claim_node_no_signer"), + modifier = Modifier.padding(14.dp), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + Spacer(Modifier.height(20.dp)) // ── NodeCode field (paste / QR forms; dashed CIRIS-V1-… accepted) ── @@ -195,7 +222,8 @@ fun ClaimNodeScreen( // resulting PINNED profile below and chain the claim then. viewModel.connectByNodeCode(codeInput.trim()) }, - enabled = !inFlight && codeInput.isNotBlank() && pinInput.isNotBlank() && !claimed, + enabled = !inFlight && !signerMissing && + codeInput.isNotBlank() && pinInput.isNotBlank() && !claimed, modifier = Modifier .fillMaxWidth() .height(48.dp) diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/viewmodels/NodeSwitcherViewModel.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/viewmodels/NodeSwitcherViewModel.kt index 5dc8ec1..111d06c 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/viewmodels/NodeSwitcherViewModel.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/viewmodels/NodeSwitcherViewModel.kt @@ -763,7 +763,13 @@ class NodeSwitcherViewModel( ) return } - _bootstrap.value = _bootstrap.value.copy(claimInProgress = true, claimError = null, claimedRole = null) + _bootstrap.value = _bootstrap.value.copy( + claimInProgress = true, + claimError = null, + claimFailure = null, + claimErrorDetail = null, + claimedRole = null, + ) viewModelScope.launch { try { // Drive the LOCAL node to claim the target. The local node does ALL @@ -788,22 +794,54 @@ class NodeSwitcherViewModel( // PIN. The local node (or, via it, the target) returns 4xx with a // body that mentions the pin (e.g. "invalid_claim_pin" / "claim // pin"); claimRemote re-throws it. - val msg = e.message.orEmpty() - val isPinRejection = msg.contains("claim_pin", ignoreCase = true) || - msg.contains("claim pin", ignoreCase = true) || - msg.contains("invalid pin", ignoreCase = true) + // FOUR OUTCOMES, NOT TWO. See [ClaimFailure]: a wrong PIN, an + // already-owned node, an unreachable target and a malformed + // NodeCode need different next actions, and the previous + // two-way split (PIN prose, else the raw message) made the + // three non-PIN cases indistinguishable to an operator + // claiming a fleet. Classified on the server's stable codes. + val failure = ai.ciris.mobile.shared.models.classifyClaimFailure(e.message) _bootstrap.value = _bootstrap.value.copy( claimInProgress = false, - claimError = if (isPinRejection) { - "The node rejected the PIN — check the one-time PIN on the node's console and try again." - } else { - "Claim failed: ${e.message}" + claimFailure = failure, + // The verbatim message is KEPT alongside the classification, + // never replaced by it: UNKNOWN exists precisely so an + // unrecognised refusal reaches the operator intact rather + // than being flattened into a guess. + claimErrorDetail = e.message, + claimError = when (failure) { + ai.ciris.mobile.shared.models.ClaimFailure.PIN_REJECTED -> + "The node rejected the PIN — check the one-time PIN on the node's console and try again." + ai.ciris.mobile.shared.models.ClaimFailure.ALREADY_CLAIMED -> + "This node already has an owner — there is nothing to claim. It prints a one-time PIN only while unclaimed." + ai.ciris.mobile.shared.models.ClaimFailure.UNREACHABLE -> + "Could not reach that node to claim it — check the address in its NodeCode and that the node is running." + ai.ciris.mobile.shared.models.ClaimFailure.BAD_NODE_CODE -> + "That NodeCode could not be read — re-scan or re-paste the full CIRIS-V1- code." + ai.ciris.mobile.shared.models.ClaimFailure.UNKNOWN -> + "Claim failed: ${e.message}" }, ) } } } + /** + * Is the local node -- the thing that SIGNS a claim -- reachable? + * + * Desktop, Android and iOS all ship a local node, so this is normally true; + * it is false when that node is not running yet, and on the web build, + * which has no local runtime at all and therefore cannot claim anything. + */ + fun checkLocalSigner() { + viewModelScope.launch { + val ready = runCatching { + apiClient.isLocalNodeUp(ai.ciris.mobile.shared.api.CIRISApiClient.LOCAL_NODE_URL) + }.getOrDefault(false) + PlatformLogger.i(TAG, "[claimAdmin] local signer ready=$ready") + _bootstrap.value = _bootstrap.value.copy(localSignerReady = ready) + } + } } /** Phase of the NodeCode bootstrap, for driving the connect/pin/claim UI. */ @@ -826,6 +864,29 @@ data class NodeBootstrapState( /** Non-null on a successful claim (e.g. "SYSTEM_ADMIN"). */ val claimedRole: String? = null, val claimError: String? = null, + /** + * WHY the claim failed, as a fact the UI can branch on -- see [ClaimFailure]. + * `null` when no claim has failed. + */ + val claimFailure: ai.ciris.mobile.shared.models.ClaimFailure? = null, + /** + * The node's own message, kept verbatim beside the classification. The + * classification is for deciding what to OFFER; this is what actually + * happened, and an unrecognised refusal must still reach the operator. + */ + val claimErrorDetail: String? = null, + /** + * Can this device sign a claim at all? + * + * The owner-binding is built and hybrid-signed by the operator's OWN node -- + * the app performs no crypto by design -- so claiming requires the local + * node to be up. `null` while unchecked, `false` when it is not reachable. + * + * Checked BEFORE the operator is asked for a NodeCode and a one-time PIN. + * Collecting both secrets and only then failing at the POST wastes a + * console trip, and the one-time PIN may be consumed or re-read for nothing. + */ + val localSignerReady: Boolean? = null, ) { val isPinned: Boolean get() = pinnedProfile != null val isAdminClaimed: Boolean get() = claimedRole != null diff --git a/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/ClaimFailureTest.kt b/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/ClaimFailureTest.kt new file mode 100644 index 0000000..e6cfb0c --- /dev/null +++ b/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/ClaimFailureTest.kt @@ -0,0 +1,92 @@ +package ai.ciris.mobile.shared.models + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The four claim outcomes an operator has to tell apart, and the one rule that + * keeps them apart: the server's CODES beat its prose. + */ +class ClaimFailureTest { + + @Test + fun codes_win_outright() { + assertEquals( + ClaimFailure.PIN_REJECTED, + classifyClaimFailure("target rejected the claim (HTTP 401): auth.claim.pin_invalid"), + ) + assertEquals( + ClaimFailure.PIN_REJECTED, + classifyClaimFailure("target rejected the claim (HTTP 400): auth.claim.pin_missing"), + ) + assertEquals( + ClaimFailure.ALREADY_CLAIMED, + classifyClaimFailure("target rejected the claim (HTTP 409): auth.claim.not_armed"), + ) + } + + @Test + fun a_reworded_server_message_still_classifies_by_code() { + // The whole reason to match the code: this sentence is not the sentence + // the server ships today, and the verdict must not depend on that. + assertEquals( + ClaimFailure.ALREADY_CLAIMED, + classifyClaimFailure("HTTP 409: auth.claim.not_armed — totally different wording here"), + ) + } + + @Test + fun prose_fallback_covers_older_nodes() { + assertEquals( + ClaimFailure.ALREADY_CLAIMED, + classifyClaimFailure( + "this node is not armed for a first-run claim (no one-time PIN) — " + + "ownership may already be claimed", + ), + ) + assertEquals(ClaimFailure.PIN_REJECTED, classifyClaimFailure("invalid claim pin")) + assertEquals( + ClaimFailure.UNREACHABLE, + classifyClaimFailure( + "target NodeCode carries no transport_hint — cannot reach the node to claim it", + ), + ) + assertEquals( + ClaimFailure.UNREACHABLE, + classifyClaimFailure("reach target node: connection refused"), + ) + assertEquals( + ClaimFailure.BAD_NODE_CODE, + classifyClaimFailure("decode target NodeCode: bad checksum"), + ) + } + + @Test + fun already_claimed_is_not_offered_as_a_retry() { + // Retrying a node that already has an owner cannot succeed, and telling + // the operator to try again sends them back to the console for a PIN + // that was never minted. + assertFalse(ClaimFailure.ALREADY_CLAIMED.isRetryable) + assertTrue(ClaimFailure.PIN_REJECTED.isRetryable) + assertTrue(ClaimFailure.UNREACHABLE.isRetryable) + assertTrue(ClaimFailure.BAD_NODE_CODE.isRetryable) + } + + @Test + fun an_already_claimed_body_is_not_read_as_a_pin_problem() { + // The not_armed sentence contains the word "PIN". Ordering the prose + // fallbacks wrongly would send the operator to hunt for a PIN that the + // node never printed, because it has an owner. + val body = "this node is not armed for a first-run claim (no one-time PIN)" + assertEquals(ClaimFailure.ALREADY_CLAIMED, classifyClaimFailure(body)) + } + + @Test + fun nothing_useful_stays_unknown() { + assertEquals(ClaimFailure.UNKNOWN, classifyClaimFailure(null)) + assertEquals(ClaimFailure.UNKNOWN, classifyClaimFailure("")) + assertEquals(ClaimFailure.UNKNOWN, classifyClaimFailure("HTTP 500 internal error")) + } +} diff --git a/client/shared/src/desktopMain/resources/localization/am.json b/client/shared/src/desktopMain/resources/localization/am.json index b478e20..55d8b26 100644 --- a/client/shared/src/desktopMain/resources/localization/am.json +++ b/client/shared/src/desktopMain/resources/localization/am.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "ማስተላለፍ ተሳክቷል! TX: {tx}", "wallet_trust_degraded": "ስልክ ሥቅ ተዳክሞ ነበር", "wallet_warning": "ማስጠንቀቂያ", + "claim_node_no_signer": "የእርስዎ ኖድ ሌላ ኖድ ከመያዙ በፊት መጀመር አለበት። ኖድዎን ይጀምሩ፣ ከዚያ እንደገና ይሞክሩ።", "attestation_actions_desc": "ለዚህ መዝገብ ያሉ እርምጃዎች", "chat_badge_message": "መልዕክት", "chat_details": "ዓይነት {type} · ወሰን {scope} · ሁኔታ {status}", diff --git a/client/shared/src/desktopMain/resources/localization/ar.json b/client/shared/src/desktopMain/resources/localization/ar.json index 3b025d4..31d7e78 100644 --- a/client/shared/src/desktopMain/resources/localization/ar.json +++ b/client/shared/src/desktopMain/resources/localization/ar.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "نجح التحويل! TX: {tx}", "wallet_trust_degraded": "تدهورت ثقة الأجهزة", "wallet_warning": "تحذير", + "claim_node_no_signer": "يجب تشغيل عقدتك قبل أن تتمكن من المطالبة بعقدة أخرى. شغّل عقدتك، ثم حاول مرة أخرى.", "attestation_actions_desc": "إجراءات هذا السجل", "chat_badge_message": "رسالة", "chat_details": "النوع {type} · النطاق {scope} · الحالة {status}", diff --git a/client/shared/src/desktopMain/resources/localization/bn.json b/client/shared/src/desktopMain/resources/localization/bn.json index 30f1052..744a4b8 100644 --- a/client/shared/src/desktopMain/resources/localization/bn.json +++ b/client/shared/src/desktopMain/resources/localization/bn.json @@ -2925,6 +2925,7 @@ "wallet_transfer_success": "ট্রান্সফার সফল! TX: {tx}", "wallet_trust_degraded": "হার্ডওয়্যার বিশ্বাস হ্রাস পেয়েছে", "wallet_warning": "সতর্কতা", + "claim_node_no_signer": "আরেকটি নোড দাবি করার আগে আপনার নোড চালু থাকতে হবে। আপনার নোড চালু করুন, তারপর আবার চেষ্টা করুন।", "attestation_actions_desc": "এই রেকর্ডের জন্য পদক্ষেপ", "chat_badge_message": "বার্তা", "chat_details": "ধরন {type} · পরিসর {scope} · অবস্থা {status}", diff --git a/client/shared/src/desktopMain/resources/localization/de.json b/client/shared/src/desktopMain/resources/localization/de.json index 74e684c..1bb19cb 100644 --- a/client/shared/src/desktopMain/resources/localization/de.json +++ b/client/shared/src/desktopMain/resources/localization/de.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Überweisung erfolgreich! TX: {tx}", "wallet_trust_degraded": "Hardware-Vertrauen herabgestuft", "wallet_warning": "Warnung", + "claim_node_no_signer": "Ihr Node muss gestartet sein, bevor er einen anderen Knoten übernehmen kann. Starten Sie Ihren Node und versuchen Sie es erneut.", "attestation_actions_desc": "Aktionen für diesen Datensatz", "chat_badge_message": "NACHRICHT", "chat_details": "Typ {type} · Bereich {scope} · Status {status}", diff --git a/client/shared/src/desktopMain/resources/localization/en.json b/client/shared/src/desktopMain/resources/localization/en.json index 8561878..336913c 100644 --- a/client/shared/src/desktopMain/resources/localization/en.json +++ b/client/shared/src/desktopMain/resources/localization/en.json @@ -2993,7 +2993,8 @@ "wallet_transfer_note": "Transfer to another Base address for fiat cashout", "wallet_transfer_success": "Transfer successful! TX: {tx}", "wallet_trust_degraded": "Hardware Trust Degraded", - "wallet_warning": "Warning" + "wallet_warning": "Warning", + "claim_node_no_signer": "Your node must be started before it can claim another node. Start your node, then try again." }, "moderation": { "ladder": { diff --git a/client/shared/src/desktopMain/resources/localization/es.json b/client/shared/src/desktopMain/resources/localization/es.json index 66a6762..6b600c2 100644 --- a/client/shared/src/desktopMain/resources/localization/es.json +++ b/client/shared/src/desktopMain/resources/localization/es.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "¡Transferencia exitosa! TX: {tx}", "wallet_trust_degraded": "Confianza de Hardware Degradada", "wallet_warning": "Advertencia", + "claim_node_no_signer": "Tu nodo debe estar iniciado antes de poder reclamar otro nodo. Inicia tu nodo e inténtalo de nuevo.", "attestation_actions_desc": "Acciones para este registro", "chat_badge_message": "MENSAJE", "chat_details": "tipo {type} · ámbito {scope} · estado {status}", diff --git a/client/shared/src/desktopMain/resources/localization/fa.json b/client/shared/src/desktopMain/resources/localization/fa.json index d9caea0..0fda01b 100644 --- a/client/shared/src/desktopMain/resources/localization/fa.json +++ b/client/shared/src/desktopMain/resources/localization/fa.json @@ -2930,6 +2930,7 @@ "wallet_transfer_success": "انتقال با موفقیت انجام شد! TX: {tx}", "wallet_trust_degraded": "افت اعتماد سخت‌افزاری", "wallet_warning": "هشدار", + "claim_node_no_signer": "گرهٔ شما باید پیش از ادعای گره‌ای دیگر راه‌اندازی شده باشد. گرهٔ خود را راه‌اندازی کنید و دوباره تلاش کنید.", "attestation_actions_desc": "اقدامات برای این رکورد", "chat_badge_message": "پیام", "chat_details": "نوع {type} · محدوده {scope} · وضعیت {status}", diff --git a/client/shared/src/desktopMain/resources/localization/fr.json b/client/shared/src/desktopMain/resources/localization/fr.json index 10abf12..5716835 100644 --- a/client/shared/src/desktopMain/resources/localization/fr.json +++ b/client/shared/src/desktopMain/resources/localization/fr.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfert réussi ! TX: {tx}", "wallet_trust_degraded": "Confiance Matérielle Dégradée", "wallet_warning": "Avertissement", + "claim_node_no_signer": "Votre nœud doit être démarré avant de pouvoir revendiquer un autre nœud. Démarrez votre nœud, puis réessayez.", "attestation_actions_desc": "Actions pour cet enregistrement", "chat_badge_message": "MESSAGE", "chat_details": "type {type} · portée {scope} · statut {status}", diff --git a/client/shared/src/desktopMain/resources/localization/ha.json b/client/shared/src/desktopMain/resources/localization/ha.json index 7ac4491..79baae8 100644 --- a/client/shared/src/desktopMain/resources/localization/ha.json +++ b/client/shared/src/desktopMain/resources/localization/ha.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Aika ya yi nasara! TX: {tx}", "wallet_trust_degraded": "Tabbas Kayan Aiki Ya Rage", "wallet_warning": "Gargaɗi", + "claim_node_no_signer": "Dole ne a fara kumburinka kafin ya iya karɓar wani kumburi. Fara kumburinka, sannan ka sake gwadawa.", "attestation_actions_desc": "Ayyuka don wannan rikodin", "chat_badge_message": "SAƘO", "chat_details": "nau'i {type} · iyaka {scope} · matsayi {status}", diff --git a/client/shared/src/desktopMain/resources/localization/hi.json b/client/shared/src/desktopMain/resources/localization/hi.json index 7654f45..d483015 100644 --- a/client/shared/src/desktopMain/resources/localization/hi.json +++ b/client/shared/src/desktopMain/resources/localization/hi.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "स्थानांतरण सफल! TX: {tx}", "wallet_trust_degraded": "हार्डवेयर ट्रस्ट कम हुआ", "wallet_warning": "चेतावनी", + "claim_node_no_signer": "किसी अन्य नोड का दावा करने से पहले आपका नोड शुरू होना चाहिए। अपना नोड शुरू करें, फिर दोबारा प्रयास करें।", "attestation_actions_desc": "इस रिकॉर्ड के लिए कार्रवाइयाँ", "chat_badge_message": "संदेश", "chat_details": "प्रकार {type} · दायरा {scope} · स्थिति {status}", diff --git a/client/shared/src/desktopMain/resources/localization/id.json b/client/shared/src/desktopMain/resources/localization/id.json index 809bf6a..aa8a9af 100644 --- a/client/shared/src/desktopMain/resources/localization/id.json +++ b/client/shared/src/desktopMain/resources/localization/id.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer berhasil! TX: {tx}", "wallet_trust_degraded": "Kepercayaan Perangkat Keras Menurun", "wallet_warning": "Peringatan", + "claim_node_no_signer": "Node Anda harus dijalankan terlebih dahulu sebelum dapat mengklaim node lain. Jalankan node Anda, lalu coba lagi.", "attestation_actions_desc": "Tindakan untuk catatan ini", "chat_badge_message": "PESAN", "chat_details": "jenis {type} · lingkup {scope} · status {status}", diff --git a/client/shared/src/desktopMain/resources/localization/it.json b/client/shared/src/desktopMain/resources/localization/it.json index 1748af9..38dd3e2 100644 --- a/client/shared/src/desktopMain/resources/localization/it.json +++ b/client/shared/src/desktopMain/resources/localization/it.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Trasferimento riuscito! TX: {tx}", "wallet_trust_degraded": "Fiducia Hardware Degradata", "wallet_warning": "Avviso", + "claim_node_no_signer": "Il tuo nodo deve essere avviato prima di poter rivendicare un altro nodo. Avvia il tuo nodo, quindi riprova.", "attestation_actions_desc": "Azioni per questo record", "chat_badge_message": "MESSAGGIO", "chat_details": "tipo {type} · ambito {scope} · stato {status}", diff --git a/client/shared/src/desktopMain/resources/localization/ja.json b/client/shared/src/desktopMain/resources/localization/ja.json index 07f3851..1dfa258 100644 --- a/client/shared/src/desktopMain/resources/localization/ja.json +++ b/client/shared/src/desktopMain/resources/localization/ja.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "送金成功!TX: {tx}", "wallet_trust_degraded": "ハードウェア信頼が低下しました", "wallet_warning": "警告", + "claim_node_no_signer": "他のノードの所有権を取得するには、まずお客様のノードを起動する必要があります。ノードを起動してから、もう一度お試しください。", "attestation_actions_desc": "このレコードに対する操作", "chat_badge_message": "メッセージ", "chat_details": "種別 {type} · スコープ {scope} · 状態 {status}", diff --git a/client/shared/src/desktopMain/resources/localization/ko.json b/client/shared/src/desktopMain/resources/localization/ko.json index a766a42..ac48476 100644 --- a/client/shared/src/desktopMain/resources/localization/ko.json +++ b/client/shared/src/desktopMain/resources/localization/ko.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "전송 성공! TX: {tx}", "wallet_trust_degraded": "하드웨어 신뢰 저하됨", "wallet_warning": "경고", + "claim_node_no_signer": "노드를 시작해야 다른 노드의 소유권을 주장할 수 있습니다. 노드를 시작한 후 다시 시도하세요.", "attestation_actions_desc": "이 레코드에 대한 작업", "chat_badge_message": "메시지", "chat_details": "유형 {type} · 범위 {scope} · 상태 {status}", diff --git a/client/shared/src/desktopMain/resources/localization/mr.json b/client/shared/src/desktopMain/resources/localization/mr.json index 0c7cf1f..2fa2e8f 100644 --- a/client/shared/src/desktopMain/resources/localization/mr.json +++ b/client/shared/src/desktopMain/resources/localization/mr.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer यशful! TX: {tx}", "wallet_trust_degraded": "हार्डवेअर विश्वास खालावला", "wallet_warning": "इशारा", + "claim_node_no_signer": "आणखी एका नोडचा दावा करण्यापूर्वी तुमचा नोड सुरू असणे आवश्यक आहे. तुमचा नोड सुरू करा आणि नंतर पुन्हा प्रयत्न करा.", "attestation_actions_desc": "या नोंदीसाठी क्रिया", "chat_badge_message": "संदेश", "chat_details": "प्रकार {type} · व्याप्ती {scope} · स्थिती {status}", diff --git a/client/shared/src/desktopMain/resources/localization/my.json b/client/shared/src/desktopMain/resources/localization/my.json index aa01418..72cc1a3 100644 --- a/client/shared/src/desktopMain/resources/localization/my.json +++ b/client/shared/src/desktopMain/resources/localization/my.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer အောင်မြင်ful! TX: {tx}", "wallet_trust_degraded": "Hardware ယုံကြည်မှု အားနည်းသွားပြီ", "wallet_warning": "သတိပေး", + "claim_node_no_signer": "အခြား Node ကို ပိုင်ဆိုင်မှုပြောရန် သင်၏ node ကို အရင်စတင်ထားရမည်။ သင်၏ node ကို စတင်ပြီး ထပ်မံကြိုးစားပါ။", "attestation_actions_desc": "ဤမှတ်တမ်းအတွက် လုပ်ဆောင်ချက်များ", "chat_badge_message": "မက်ဆေ့ချ်", "chat_details": "အမျိုးအစား {type} · scope {scope} · အခြေအနေ {status}", diff --git a/client/shared/src/desktopMain/resources/localization/pa.json b/client/shared/src/desktopMain/resources/localization/pa.json index ebf8145..da6082d 100644 --- a/client/shared/src/desktopMain/resources/localization/pa.json +++ b/client/shared/src/desktopMain/resources/localization/pa.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "ਟ੍ਰਾਂਸਫਰ ਸਫਲ! TX: {tx}", "wallet_trust_degraded": "ਹਾਰਡਵੇਅਰ ਭਰੋਸਾ ਘਟਿਆ", "wallet_warning": "ਚੇਤਾਵਨੀ", + "claim_node_no_signer": "ਕਿਸੇ ਹੋਰ ਨੋਡ ਦਾ ਦਾਅਵਾ ਕਰਨ ਤੋਂ ਪਹਿਲਾਂ ਤੁਹਾਡਾ ਨੋਡ ਸ਼ੁਰੂ ਹੋਣਾ ਲਾਜ਼ਮੀ ਹੈ। ਆਪਣਾ ਨੋਡ ਸ਼ੁਰੂ ਕਰੋ, ਫਿਰ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।", "attestation_actions_desc": "ਇਸ ਰਿਕਾਰਡ ਲਈ ਕਾਰਵਾਈਆਂ", "chat_badge_message": "ਸੁਨੇਹਾ", "chat_details": "ਕਿਸਮ {type} · ਦਾਇਰਾ {scope} · ਸਥਿਤੀ {status}", diff --git a/client/shared/src/desktopMain/resources/localization/pt.json b/client/shared/src/desktopMain/resources/localization/pt.json index fa5fa8c..d00c840 100644 --- a/client/shared/src/desktopMain/resources/localization/pt.json +++ b/client/shared/src/desktopMain/resources/localization/pt.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transferência bem-sucedida! TX: {tx}", "wallet_trust_degraded": "Confiança de Hardware Degradada", "wallet_warning": "Aviso", + "claim_node_no_signer": "Seu nó precisa ser iniciado antes que possa reivindicar outro nó. Inicie seu nó e tente novamente.", "attestation_actions_desc": "Ações para este registo", "chat_badge_message": "MENSAGEM", "chat_details": "tipo {type} · âmbito {scope} · estado {status}", diff --git a/client/shared/src/desktopMain/resources/localization/ru.json b/client/shared/src/desktopMain/resources/localization/ru.json index 484e28e..b4bbaeb 100644 --- a/client/shared/src/desktopMain/resources/localization/ru.json +++ b/client/shared/src/desktopMain/resources/localization/ru.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Перевод успешен! TX: {tx}", "wallet_trust_degraded": "Доверие к оборудованию ухудшено", "wallet_warning": "Предупреждение", + "claim_node_no_signer": "Ваш узел должен быть запущен, прежде чем он сможет заявить права на другой узел. Запустите узел и повторите попытку.", "attestation_actions_desc": "Действия для этой записи", "chat_badge_message": "СООБЩЕНИЕ", "chat_details": "тип {type} · область {scope} · статус {status}", diff --git a/client/shared/src/desktopMain/resources/localization/sw.json b/client/shared/src/desktopMain/resources/localization/sw.json index 522bfde..f05a8e1 100644 --- a/client/shared/src/desktopMain/resources/localization/sw.json +++ b/client/shared/src/desktopMain/resources/localization/sw.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Uhamisho umefanikiwa! TX: {tx}", "wallet_trust_degraded": "Kuaminika kwa Maunzi Kumedhoofika", "wallet_warning": "Onyo", + "claim_node_no_signer": "Nodi yako inahitaji kuanzishwa kabla ya kudai nodi nyingine. Anzisha nodi yako, kisha ujaribu tena.", "attestation_actions_desc": "Vitendo vya rekodi hii", "chat_badge_message": "UJUMBE", "chat_details": "aina {type} · wigo {scope} · hali {status}", diff --git a/client/shared/src/desktopMain/resources/localization/ta.json b/client/shared/src/desktopMain/resources/localization/ta.json index 34f5c0e..12dc310 100644 --- a/client/shared/src/desktopMain/resources/localization/ta.json +++ b/client/shared/src/desktopMain/resources/localization/ta.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "பரிமாற்றம் வெற்றிகரம்! TX: {tx}", "wallet_trust_degraded": "Hardware நம்பகத்தன்மை Degraded", "wallet_warning": "எச்சரிக்கை", + "claim_node_no_signer": "உங்கள் நோடு மற்றொரு நோடைக் கோரும் முன் தொடங்கப்பட்டிருக்க வேண்டும். உங்கள் நோடைத் தொடங்கி, மீண்டும் முயற்சிக்கவும்.", "attestation_actions_desc": "இந்தப் பதிவுக்கான செயல்கள்", "chat_badge_message": "செய்தி", "chat_details": "வகை {type} · எல்லை {scope} · நிலை {status}", diff --git a/client/shared/src/desktopMain/resources/localization/te.json b/client/shared/src/desktopMain/resources/localization/te.json index 78b6759..6a6cd1b 100644 --- a/client/shared/src/desktopMain/resources/localization/te.json +++ b/client/shared/src/desktopMain/resources/localization/te.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "బదిలీ విజయవంతమైంది! TX: {tx}", "wallet_trust_degraded": "హార్డ్‌వేర్ నమ్మకం తగ్గింది", "wallet_warning": "హెచ్చరిక", + "claim_node_no_signer": "మీ నోడ్ మరో నోడ్‌ను క్లెయిమ్ చేయడానికి ముందు ప్రారంభమై ఉండాలి. మీ నోడ్‌ను ప్రారంభించి, మళ్ళీ ప్రయత్నించండి.", "attestation_actions_desc": "ఈ రికార్డుకు చర్యలు", "chat_badge_message": "సందేశం", "chat_details": "రకం {type} · పరిధి {scope} · స్థితి {status}", diff --git a/client/shared/src/desktopMain/resources/localization/th.json b/client/shared/src/desktopMain/resources/localization/th.json index 78de09e..4cbc211 100644 --- a/client/shared/src/desktopMain/resources/localization/th.json +++ b/client/shared/src/desktopMain/resources/localization/th.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer สำเร็จful! TX: {tx}", "wallet_trust_degraded": "ความน่าเชื่อถือของฮาร์ดแวร์ลดลง", "wallet_warning": "คำเตือน", + "claim_node_no_signer": "โหนดของคุณต้องเริ่มทำงานก่อนจึงจะสามารถอ้างสิทธิ์โหนดอื่นได้ กรุณาเริ่มโหนดของคุณ แล้วลองอีกครั้ง", "attestation_actions_desc": "การดำเนินการสำหรับระเบียนนี้", "chat_badge_message": "ข้อความ", "chat_details": "ประเภท {type} · ขอบเขต {scope} · สถานะ {status}", diff --git a/client/shared/src/desktopMain/resources/localization/tr.json b/client/shared/src/desktopMain/resources/localization/tr.json index 9708b87..443adfc 100644 --- a/client/shared/src/desktopMain/resources/localization/tr.json +++ b/client/shared/src/desktopMain/resources/localization/tr.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Transfer başarılı! TX: {tx}", "wallet_trust_degraded": "Donanım Güveni Düşmüş", "wallet_warning": "Uyarı", + "claim_node_no_signer": "Başka bir düğümü sahiplenmeden önce kendi düğümünüz başlatılmış olmalıdır. Düğümünüzü başlatın ve tekrar deneyin.", "attestation_actions_desc": "Bu kayıt için eylemler", "chat_badge_message": "MESAJ", "chat_details": "tür {type} · kapsam {scope} · durum {status}", diff --git a/client/shared/src/desktopMain/resources/localization/uk.json b/client/shared/src/desktopMain/resources/localization/uk.json index fafcb63..a3ebdd0 100644 --- a/client/shared/src/desktopMain/resources/localization/uk.json +++ b/client/shared/src/desktopMain/resources/localization/uk.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Переказ успішний! TX: {tx}", "wallet_trust_degraded": "Апаратну довіру знижено", "wallet_warning": "Попередження", + "claim_node_no_signer": "Ваш вузол потрібно запустити, перш ніж він зможе заявити право на інший вузол. Запустіть свій вузол і спробуйте ще раз.", "attestation_actions_desc": "Дії для цього запису", "chat_badge_message": "ПОВІДОМЛЕННЯ", "chat_details": "тип {type} · область {scope} · статус {status}", diff --git a/client/shared/src/desktopMain/resources/localization/ur.json b/client/shared/src/desktopMain/resources/localization/ur.json index 7c17d31..39b48e6 100644 --- a/client/shared/src/desktopMain/resources/localization/ur.json +++ b/client/shared/src/desktopMain/resources/localization/ur.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "ٹرانسفر کامیاب! TX: {tx}", "wallet_trust_degraded": "ہارڈ ویئر اعتماد کمزور پڑ گیا", "wallet_warning": "انتباہ", + "claim_node_no_signer": "کسی اور نوڈ کا دعویٰ کرنے سے پہلے آپ کا نوڈ شروع ہونا ضروری ہے۔ اپنا نوڈ شروع کریں، پھر دوبارہ کوشش کریں۔", "attestation_actions_desc": "اس ریکارڈ کے لیے اقدامات", "chat_badge_message": "پیغام", "chat_details": "قسم {type} · دائرہ {scope} · حیثیت {status}", diff --git a/client/shared/src/desktopMain/resources/localization/vi.json b/client/shared/src/desktopMain/resources/localization/vi.json index 245869a..018b388 100644 --- a/client/shared/src/desktopMain/resources/localization/vi.json +++ b/client/shared/src/desktopMain/resources/localization/vi.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Chuyển khoản thành công! TX: {tx}", "wallet_trust_degraded": "Tin tưởng Suy giảm", "wallet_warning": "Cảnh báo", + "claim_node_no_signer": "Nút của bạn phải được khởi động trước khi có thể nhận quyền một nút khác. Hãy khởi động nút của bạn, rồi thử lại.", "attestation_actions_desc": "Các thao tác cho bản ghi này", "chat_badge_message": "TIN NHẮN", "chat_details": "loại {type} · phạm vi {scope} · trạng thái {status}", diff --git a/client/shared/src/desktopMain/resources/localization/yo.json b/client/shared/src/desktopMain/resources/localization/yo.json index d7f8400..0ebc93f 100644 --- a/client/shared/src/desktopMain/resources/localization/yo.json +++ b/client/shared/src/desktopMain/resources/localization/yo.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "Gbígbé ṣe àṣeyọrí! TX: {tx}", "wallet_trust_degraded": "Ìgbẹ́kẹ̀lé Ohun Ẹ̀rọ Ti Dín Kù", "wallet_warning": "Ìkìlọ̀", + "claim_node_no_signer": "Gbọ́dọ̀ bẹ̀rẹ̀ node rẹ kí ó tó lè gba node mìíràn. Bẹ̀rẹ̀ node rẹ, lẹ́yìn náà gbìyànjú lẹ́ẹ̀kan sí i.", "attestation_actions_desc": "Àwọn ìgbésẹ̀ fún àkọsílẹ̀ yìí", "chat_badge_message": "ÌFIRÁNṢẸ́", "chat_details": "irú {type} · ààlà {scope} · ipò {status}", diff --git a/client/shared/src/desktopMain/resources/localization/zh.json b/client/shared/src/desktopMain/resources/localization/zh.json index 2bcede3..060d438 100644 --- a/client/shared/src/desktopMain/resources/localization/zh.json +++ b/client/shared/src/desktopMain/resources/localization/zh.json @@ -2924,6 +2924,7 @@ "wallet_transfer_success": "转账成功!TX: {tx}", "wallet_trust_degraded": "硬件信任降级", "wallet_warning": "警告", + "claim_node_no_signer": "您的节点必须先启动才能认领另一个节点。请启动您的节点,然后重试。", "attestation_actions_desc": "此记录的操作", "chat_badge_message": "消息", "chat_details": "类型 {type} · 范围 {scope} · 状态 {status}", diff --git a/docs/FSD-remote-first-run-claim.md b/docs/FSD-remote-first-run-claim.md new file mode 100644 index 0000000..9f0f947 --- /dev/null +++ b/docs/FSD-remote-first-run-claim.md @@ -0,0 +1,259 @@ +# FSD — Remote first-run claim + +**Status:** draft for review +**Author:** CIRISClient +**Covers:** claiming a remote CIRIS node from this client — both the +*configured-but-unclaimed* case and the *configure-and-claim* case. + +--- + +## 1. The gap + +There are CIRIS nodes running as research agents that are **fully configured and +unclaimed**. A node in that state works, serves its API, and has no responsible +party bound to it. Today an operator's route to claiming one from this client is +narrow and undiscovered, and for some client deployments it does not exist. + +Two cases, which are not the same problem: + +| | node state | what the operator needs | +|---|---|---| +| **A** | configured, unclaimed | claim it: bind an owner | +| **B** | bare, unconfigured | run first-run config against it, *then* claim | + +Case A is the one in front of us. Case B is the more ambitious one, and §3 +establishes that it is **blocked server-side today** — it is not a client +omission, and no amount of client work reaches it. + +--- + +## 2. What exists today + +**`ClaimNodeScreen`** (`ui/screens/ClaimNodeScreen.kt`) already takes a NodeCode, +a claim PIN and a display name, and drives `claimRemote`. It is reached from +`ManageNodesScreen` → "claim ownership" (`CIRISApp.kt:3583`). + +**`CIRISApiClient.claimRemote`** (`api/CIRISApiClient.kt:2264`) POSTs +`{node_code, claim_pin, cohort_scope}` to **the local node's** +`/v1/setup/claim-remote`. The app performs no crypto: the local node decodes the +NodeCode, builds and hybrid-signs the owner-binding +`delegates_to(user → target, infra:*)` in its own substrate, and POSTs the signed +artifact to **the target's** `/v1/setup/root`. + +**The first-run wizard** self-claims by the same path with the target set to +itself (`SetupViewModel.claimLocalNodeOwnership`, `viewmodels/SetupViewModel.kt:1052`), +taking the PIN from `claimPinProvider` — which reads the local node's +`/claim_pin` file. That provider returns null for any node this device did +not start, and the wizard then surfaces "claim PIN not captured … you can claim +ownership later from the Network surface". + +So the machinery for a remote claim exists. What is missing is above it. + +--- + +## 3. The contract that constrains the design + +Verified against CIRISServer 0.5.190/0.5.191 source and the released +`x86_64-unknown-linux-gnu` binary. + +### 3.1 Only ONE setup route is reachable off-host + +`src/auth/bootstrap.rs` builds two routers and merges them — +`claim.merge(loopback_reads)`, where `loopback_reads` carries +`require_loopback`: + +| route | reachable remotely? | +|---|---| +| `POST /v1/setup/root` | **yes** | +| `GET /v1/setup/status` | no — loopback only | +| `GET /v1/setup/owned-nodes` | no — loopback only | +| `GET /v1/setup/consent-disclosure` | no — loopback only | + +A non-loopback caller gets `403 "setup routes are localhost-only (run the wizard +on the node's own host)"` (`src/auth/loopback.rs:44`). + +**Measured, not inferred.** Released `ciris-server v0.5.190`, binding `0.0.0.0`, +queried over loopback and over the host's own LAN address (192.168.50.8): + +| route | via `127.0.0.1` | via LAN address | +|---|---|---| +| `GET /v1/setup/status` | 200 | **403** | +| `GET /v1/setup/owned-nodes` | 200 | **403** | +| `GET /v1/system/health` | 200 | 200 | +| `GET /v1/setup/root` | 405 | 405 | + +The last row is the useful one: `/v1/setup/root` answers **405 Method Not Allowed +from the LAN**, not 403. A loopback-layered route rejects before it ever +considers the method, so a 405 off-host is positive proof that the claim route +sits outside the guard — the claim is reachable remotely, and only the reads are +not. + +This is a deliberate trust boundary, not an oversight: `/v1/setup/root` can be +open because it authenticates on its own terms — the one-time claim PIN plus a +signed owner-binding — while the reads would otherwise leak a node's setup and +ownership posture to anyone who can reach it. + +### 3.2 Consequences, stated plainly + +1. **This client cannot ask a remote node whether it is set up, or whether it has + an owner.** `getSetupStatus()` and the `owned-nodes` probe behind + `nodeHasOwner()` both 403 off-host. Anything the UI wants to say about a + remote node's claim state must come from the operator or from a route outside + `/v1/setup/*`. + +2. **Case B is blocked.** The first-run wizard reads `/v1/setup/status` to know + there is a first run at all, and `/v1/setup/consent-disclosure` to render what + joining grants in the substrate's own words. Both are loopback-only, so the + wizard cannot run against a remote node. Closing Case B requires a CIRISServer + change — see §7. + +3. **Claiming runs through the local node, and every shipped platform has one.** + `/v1/setup/claim-remote` is where the signing happens, and it is loopback-only + *and* first-run-gated (`src/claim_remote.rs:391` — owner-gated once owned, + open during first-run, "loopback-only via the setup-route guard"). The app is + deliberately not allowed to do crypto itself, so the claim needs a substrate + holding the operator's identity. + + That substrate is always present on the platforms we ship to the stores: + desktop, **Android and iOS all run a local node** (`PythonRuntime.{desktop, + android,ios}.kt`). The precondition is therefore not "do you have a node" but + "is your node up and are you signed in to it" — a state the client can check + before asking for anything. + + The one exception is the **wasm/web** build, which has no local runtime + (`PythonRuntime.wasmJs.kt` — "Web mode - connecting to remote server"). Web is + remote-only and cannot claim; it must say so rather than offer the flow. + +### 3.3 The claim body + +`POST /v1/setup/root` takes `{node_code, cohort_scope, claim_pin, owner_binding}` +where `owner_binding` is the user-signed `delegates_to` +(`CIRISServer tests/ownership.rs:376`). `201` on success, echoing +`identity_key_id`, `cohort_scope`, `role: SYSTEM_ADMIN` and +`owner_binding_attestation_id`. + +--- + +## 4. Design — Case A: claim a configured, unclaimed remote node + +**Shape:** operator supplies the target's NodeCode and its one-time claim PIN +(read from that node's console — the PIN never travels over HTTP by design); the +**local** node signs and delivers the claim. + +``` + operator ──NodeCode + PIN──▶ client + │ POST /v1/setup/claim-remote (loopback) + ▼ + local node ──signs owner-binding──┐ + │ POST /v1/setup/root + ▼ + target node +``` + +### 4.1 What to build + +**A1 — An entry point that matches the task.** Claiming several research agents +in a sitting is the actual workload, and today the flow is one node at a time +buried under ManageNodes. `ClaimNodeScreen` already offers "claim another" +(`ClaimNodeScreen.kt:292`); the work is to make the entry point reachable and +named for the job, and to keep the entered display name with the claimed node. + +**A2 — Check the precondition before asking for secrets.** The claim is signed +by the operator's own node, so it needs that node up and a live session on it. +Both are knowable *before* the operator types a NodeCode and a PIN, and the +screen should say which one is missing up front rather than collecting both and +failing at the POST. On web, where there is no local runtime at all, the flow is +not offered. + +**A3 — Distinguish the PIN failures.** A wrong PIN, an already-claimed node, and +an unreachable target are three different situations with three different next +actions. `NodeSwitcherViewModel.claimRemoteNode` (`viewmodels/NodeSwitcherViewModel.kt:722`) +already inspects the body for `invalid_claim_pin`; the screen should render that +distinction rather than one generic failure. + +**A4 — Do not claim to know remote setup state.** Since §3.2(1) makes it +unknowable, the UI must not imply it. No "this node is unclaimed" badge for a +remote node; the operator is the source of that fact. + +### 4.2 Explicitly NOT in Case A + +Auto-discovery of unclaimed nodes on a network. The client cannot probe claim +state off-host, and a scan that inferred it from other signals would be guessing +about ownership — the one thing this flow exists to establish precisely. + +--- + +## 5. Design — Case B: configure and claim a remote node + +Blocked today (§3.2(2)). Two honest options: + +**B1 — Configure on the node's own host, claim from here.** No server change. +The operator runs first-run on the remote host (console/SSH — which they already +need for the PIN), and the node then falls into Case A. This is what the current +trust boundary is telling us to do, and it is the recommendation for now. + +**B2 — Make remote first-run possible.** Requires CIRISServer to expose, off-host +and safely, what the wizard needs: setup status and the consent disclosure. That +is a security decision about a deliberate boundary, and it belongs to the server +team, not to this client. §7 states the ask. + +--- + +## 6. UX — where the PIN is entered + +One rule, in both cases: **the claim PIN is typed by a human who read it from the +target node's console.** It is written `0600` to `/claim_pin`, served by no +route, and `/v1/setup/status` carries only its *path*, never its value +(`CIRISServer tests/claim_pin_file_is_declared.rs`). + +The local first-run wizard auto-fills it because the client is on the same host +and can read the file. That convenience does not generalise, and the code should +stop implying it does: `claimPinProvider` returning null is the normal case for +any node this device did not start, not an error condition. + +--- + +## 7. Server dependency (Case B only) + +For CIRISServer, if remote first-run is wanted: + +> The first-run wizard needs `GET /v1/setup/status` and +> `GET /v1/setup/consent-disclosure` off-host. Both are behind +> `require_loopback` today. What would make them safe to expose — the claim PIN +> as a bearer, a short-lived setup token minted on the console, an explicit +> `--allow-remote-setup` flag, or nothing at all? + +Until that is answered, Case B is B1. + +--- + +## 8. Testing + +The walk-test matrix (`testing/README.md`) grows a corner: + +| corner | node | asserts | +|---|---|---| +| `remote-unclaimed` | configured, no owner | the claim flow reaches `/v1/setup/root` and binds an owner | + +**A LIMITATION IN THE CURRENT HARNESS MUST BE FIXED FIRST, AND IT IS MINE.** The +existing "remote" corners put a facade on `127.0.0.1`, so the node sees a +**loopback** peer and the loopback-only routes answer normally. Those corners +therefore exercise the client's remote *configuration* path but not remote +*reachability* — a genuinely off-host client gets 403s that the harness never +sees. Testing any of §3 honestly requires the node to observe a non-loopback +source address (bind a second interface, or reach the host by its LAN address). +Until that is done, no walk-test result should be read as evidence about remote +behaviour. + +--- + +## 9. Summary of decisions + +1. Case A is buildable now and is the priority; the signing path already exists. +2. Case B is server-blocked; recommend B1 and raise §7 with the server team. +3. The client must never present remote setup/claim state it cannot observe. + Desktop, Android and iOS all carry a local node, so a signer always exists; + web is remote-only and cannot claim. +4. The claim PIN stays human-entered off-host. That is the design, not a gap. +5. The walk-test harness must see a non-loopback peer before it can test any of + this. diff --git a/testing/README.md b/testing/README.md new file mode 100644 index 0000000..58ae12c --- /dev/null +++ b/testing/README.md @@ -0,0 +1,142 @@ +# The walk tests + +CI compiled this client, unit-tested `:shared`, built a jar and a wheel, checked +facts about PyPI — and never once started the app. + +Every defect this repo has handed to the server team lived past all of that. A +Reset that exits instead of returning to the wizard. An Android element that +registered a click handler but not itself, so it was invisible to automation +while looking fine to a human. A debug export written where no file manager on +the platform can see it. A `PlatformLogger` that never fed `DebugLogBuffer`. +None of those are visible to a compiler, and all of them are obvious within +three seconds of the app running. + +This is the missing half: it starts the real app against real nodes and drives +it through the automation server the client already ships. + +## The matrix + +The client is meant to be complete against a node that is **local or remote**, +and that is **carrying a brain or not**. Those are independent axes resolved by +two different mechanisms, so a harness that points the app at one auto-started +node tests one corner and calls it done. + +| corner | node | brain | what only this corner sees | +|---|---|---|---| +| `local-node` | the app launches it | none | the self-launch path, and the claim-PIN read the shipped wheel depends on | +| `remote-node` | pre-started, via `CIRIS_API_URL` | none | that the client does **not** quietly start a local node it wasn't asked for | +| `remote-agent` | pre-started | folded, answering | the agent surface against a node that declares one | +| `remote-undetermined` | pre-started | folded, **not** answering | that the client does not *latch* — undetermined is a retry signal, not a verdict | + +**Location** is not "which URL". `PythonRuntime.desktop.startServer()` probes +its configured URL and only launches `ciris-server` if nothing answers, so the +axis is *who started the node* — and the remote corners assert the negative by +session id, not by port liveness (they run a real node themselves as the +facade's substrate, so "a node is answering" is true for a blameless client). + +**Brain** is `ClientMode`, derived from `/v1/system/health` (CIRISServer#390). +A real bare node gives NODE for free — the released binary boots in ~2s. A real +agent would need a brain and an LLM bill, and still could not produce +`undetermined` on demand, because that state is a race. So the brain axis is +served by `BrainFacade`: a proxy that rewrites **only** the routes the mode gate +reads, exactly as the contract documents them, and passes everything else +through to the real node untouched. Nothing the client actually calls is +stubbed. + +The facade presents *coherent* nodes, and that is load-bearing rather than +tidiness. `clientModeFrom` demotes an answering brain to NODE when the brain +reports itself unconfigured (CIRISAgent#1075), and `undetermined` requires +`!brainUnconfigured`. Both the agent and undetermined corners initially failed a +**blameless client** because the facade folded a brain onto a node whose +`/v1/setup/status` still said `setup_required: true`. An agent presents both, or +it is not an agent. + +### What the "remote" corners do NOT test + +The facade runs on `127.0.0.1`, so the node sees a **loopback** peer and its +loopback-only routes answer normally. `GET /v1/setup/status` and +`/v1/setup/owned-nodes` are localhost-only and return 403 to a genuinely off-host +client (measured against the released binary; see +`docs/FSD-remote-first-run-claim.md` §3.1). + +So these corners exercise the client's remote *configuration* path -- it is +pointed at another URL, it must not launch a node, it derives its mode from that +URL -- but not remote *reachability*. A real off-host client meets 403s this +harness never produces. Until a corner makes the node observe a non-loopback +source address, no result here is evidence about off-host behaviour. + +### The corner this cannot cover + +**local × agent.** The released node binds 4242/4243 with no port override — +`ciris-server [--home ] [--key-id ]` is the entire usage — so the +facade cannot sit where a self-launched node must be. Folding a real brain onto +a local node is the downstream mobile/manual test. It is not faked here, and it +is not silently absent: `run_e2e.py` prints every case it skips. + +## Running it + +```bash +# one corner, against a node you already have +python3 -m testing.run_e2e --corner remote-agent \ + --jar client/desktopApp/build/compose/jars/CIRIS-linux-x64-*.jar + +# the whole matrix, with the node CI uses +gh release download v0.5.190 -R CIRISAI/CIRISServer \ + -p 'ciris-server-*-x86_64-unknown-linux-gnu.tar.gz' -D node +tar xzf node/ciris-server-*.tar.gz -C node && chmod +x node/ciris-server +python3 -m testing.run_e2e --corner all --node-bin node/ciris-server \ + --jar client/desktopApp/build/compose/jars/CIRIS-linux-x64-*.jar \ + --report e2e-report.json +``` + +Needs a display. With `DISPLAY` set it uses it; without one it wraps the app in +`xvfb-run`, which is how CI runs (`xvfb` **and** `xauth` — `xvfb-run` fails with +a bare "xauth: not found" otherwise). + +`--reclaim` kills a leftover **test-mode** app holding the test port. That is +safe by construction — a test-mode app is a previous run's artefact — and +nothing else on the port is ever killed; the run stops instead. + +Whole matrix: about four minutes. Three corners take ~5s each; the undetermined +corner takes ~145s because the client spends its full 60s retry budget on the +probe, which is the behaviour under test. + +## The pieces + +| file | what it is | +|---|---| +| `driver.py` | the automation-server client — `/health`, `/tree`, `/screen`, `/state`, `/click`, `/input`, `/screenshot`. stdlib only | +| `node_fixture.py` | `RealNode` (a released `ciris-server`) and `BrainFacade` (the brain axis) | +| `cases.py` | the assertions, each declaring the corners it applies to | +| `run_e2e.py` | stands up each corner, launches the app, drives it, writes the report | + +The **server** side of the automation surface was already ours +(`client/desktopApp/.../testing/TestAutomationServer.kt`, and the Android and +iOS actuals). What lived only in CIRISAgent was the thing that drives it: +`tools/test_desktop_wipe_setup.sh`, 191 lines of `curl | grep` against five of +the sixteen routes. This is that, taken over and made a library. + +`/state` is new here. Inferring node-vs-agent from which widgets are on screen +asserts the *layout* rather than the *gate*, and passes a client that renders +agent affordances against a bare node — so the app publishes its own account of +`clientMode` and the node URL it settled on, and the harness asserts that. + +## How it fails + +Loudly, with evidence. A failing case captures a screenshot, the element tree, +the app log and the node log into the report. A corner that could not be stood +up is an **error**, never a skip. + +Some of that is scar tissue from building it. `curl -s` against a dead server +prints nothing and exits 0, so the shell script this replaces read a missing app +as a screen named `""` and walked on. The fixture refuses to adopt a node it did +not start, because a leftover on the fixed port answers exactly like a fresh +one. An explicit `--node-bin` that does not exist is fatal rather than falling +back to PATH, because there are two different programs called `ciris-server` in +this ecosystem and resolving to the wrong one runs the whole suite and reports +on it as though it were right. + +Each of those was found by this harness's own mutation tests, which are the +reason to trust a green run: a node binary that cannot start turns the local +corner red, and a brain that answers when the corner says it should not turns +the latch detector red. A gate that cannot fail is not a gate. diff --git a/testing/__init__.py b/testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/testing/cases.py b/testing/cases.py new file mode 100644 index 0000000..1852212 --- /dev/null +++ b/testing/cases.py @@ -0,0 +1,224 @@ +""" +What we assert, and against which corner. + +A case is a plain function taking a [Context] and raising on failure. Each is +declared with the corners it applies to, so `run_e2e.py --corner X` runs exactly +the cases that mean something for X and skips the rest EXPLICITLY -- a skip is +printed and lands in the report, never silently dropped. + +The assertions are deliberately about things that have actually broken here: + +* `startup_completes` -- the app leaving Startup at all. Every node-facing + regression in this repo has shown up first as a client that sits on Startup + forever, and nothing in CI could see it because nothing in CI ever started + the app. + +* `elements_are_registered` -- `/tree` non-empty. CIRISClient#7 was an Android + `testableWithHandler` that wired a click handler but never called + `registerElement`, so the element was invisible to automation while looking + fine to a human. An empty tree on a populated screen is that bug. + +* `did_not_launch_a_node` / `did_launch_a_node` -- the LOCATION axis, asserted + on behaviour rather than configuration. A client told to use a remote node + that helpfully starts a local one anyway is a data-residency bug, not a + convenience. + +* `claim_pin_is_readable` -- the first-run path the desktop wheel actually + broke: the launcher starts the node and then spawns the UI, so the stdout + banner capture never runs and the PIN has to come from the node's home file. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable + +from .driver import DriverError, TestAutomationServer + +# The corners. See node_fixture for why local-agent is not among them. +LOCAL_NODE = "local-node" +REMOTE_NODE = "remote-node" +REMOTE_AGENT = "remote-agent" +REMOTE_UNDETERMINED = "remote-undetermined" +ALL_CORNERS = (LOCAL_NODE, REMOTE_NODE, REMOTE_AGENT, REMOTE_UNDETERMINED) + +# Where startup is allowed to land. Setup on a fresh home, Login once claimed. +TERMINAL_SCREENS = {"Login", "Setup", "Startup"} + + +@dataclass +class Context: + """Everything a case is allowed to look at.""" + + corner: str + app: TestAutomationServer + node_url: str + #: True when the harness -- not the app -- started the real node. + node_was_prestarted: bool + #: The node home, when there is one we own. + node_home: object = None + #: Set by the runner: did the APP itself spawn a node? Answered by session + #: id, not by "is the port answering" -- the remote corners run a real node + #: as the facade's substrate, so port-liveness was true for a blameless + #: client and this case failed against correct behaviour. + app_spawned_node: bool = False + notes: list[str] = field(default_factory=list) + + +CASES: list["Case"] = [] + + +@dataclass +class Case: + name: str + corners: tuple[str, ...] + fn: Callable[[Context], None] + why: str = "" + + +def case(name: str, corners: tuple[str, ...], why: str = ""): + def deco(fn: Callable[[Context], None]) -> Callable[[Context], None]: + CASES.append(Case(name=name, corners=corners, fn=fn, why=why)) + return fn + + return deco + + +# -------------------------------------------------------------------------- +# Cases +# -------------------------------------------------------------------------- + + +@case("startup_completes", ALL_CORNERS, "a client that never leaves Startup is the recurring failure") +def startup_completes(ctx: Context) -> None: + ctx.app.wait_for_server(timeout=120) + screen = ctx.app.screen() + # Startup is transient; give it room, then demand it moved. + import time + + deadline = time.monotonic() + 120 + while time.monotonic() < deadline and screen in ("", "unknown", "Startup"): + time.sleep(1.0) + screen = ctx.app.screen() + if screen in ("", "unknown", "Startup"): + raise AssertionError( + f"app never left Startup against a {ctx.corner} node " + f"(screen={screen!r}, {len(ctx.app.tree())} elements registered)" + ) + if screen not in TERMINAL_SCREENS: + ctx.notes.append(f"landed on {screen!r}, which is past the expected first stop") + ctx.notes.append(f"settled on {screen!r}") + + +@case("elements_are_registered", ALL_CORNERS, "an empty tree on a populated screen is CIRISClient#7") +def elements_are_registered(ctx: Context) -> None: + tags = ctx.app.tags() + if not tags: + raise AssertionError( + f"no elements registered with the automation server on screen " + f"{ctx.app.screen()!r} -- either the screen is genuinely empty or " + f"registerElement is not being called (CIRISClient#7)" + ) + ctx.notes.append(f"{len(tags)} elements registered") + + +@case("did_not_launch_a_node", (REMOTE_NODE, REMOTE_AGENT, REMOTE_UNDETERMINED), + "a client pointed at a remote node must not quietly start a local one") +def did_not_launch_a_node(ctx: Context) -> None: + if ctx.app_spawned_node: + raise AssertionError( + "the client spawned a ciris-server of its own while configured for " + f"the remote node at {ctx.node_url}. A client told to use someone " + "else's node must not quietly stand up a local one." + ) + ctx.notes.append("no local node was launched, as required") + + +@case("did_launch_a_node", (LOCAL_NODE,), "the self-launch path, which the wheel actually ships") +def did_launch_a_node(ctx: Context) -> None: + if ctx.node_was_prestarted: + raise AssertionError("harness bug: the local corner must not pre-start the node") + if not ctx.app_spawned_node: + raise AssertionError( + "the client never launched a node. `ciris-server` was on PATH and " + "nothing was answering, so startServer() should have spawned one." + ) + ctx.notes.append("client launched its own node") + + +@case("automation_surface_answers", ALL_CORNERS, "the driver's own contract with the app") +def automation_surface_answers(ctx: Context) -> None: + h = ctx.app.health() + if not h or h.get("status") != "ok": + raise AssertionError(f"/health did not report ok: {h!r}") + if h.get("testMode") is not True: + raise AssertionError( + "the app is not in test mode -- CIRIS_TEST_MODE was not honoured, " + "and every element assertion below would be vacuous" + ) + # /tree and /screen must agree about the screen, or one of them is stale. + tree_screen = None + try: + raw = ctx.app._call("GET", "/tree") + tree_screen = raw.get("screen") if isinstance(raw, dict) else None + except DriverError: + pass + if tree_screen is not None and tree_screen != ctx.app.screen(): + ctx.notes.append(f"/tree says {tree_screen!r}, /screen says {ctx.app.screen()!r}") + + +@case("mode_gate_matches_corner", ALL_CORNERS, + "the brain axis, asserted on the gate rather than on the layout") +def mode_gate_matches_corner(ctx: Context) -> None: + """ + NODE for a bare node, AGENT for one carrying a brain, and `unset` for a + brain that is folded but not answering. + + That third expectation is the point of the corner. `undetermined` is a + RETRY signal, and a client that resolves it by picking NODE looks correct + on every screen while being wrong about the one fact it exists to know. + The client retries for the startup budget (60s by default) and then leaves + the gate unset, so this waits past that budget before believing an answer. + """ + expected = { + LOCAL_NODE: "NODE", + REMOTE_NODE: "NODE", + REMOTE_AGENT: "AGENT", + REMOTE_UNDETERMINED: "unset", + }[ctx.corner] + + if ctx.corner == REMOTE_UNDETERMINED: + # Watch the WHOLE budget: latching shows up as a decided value at any + # point in it, and checking only at the end would miss a client that + # latched early and a later probe happened to unset. + import time + + deadline = time.monotonic() + 80 + while time.monotonic() < deadline: + mode = ctx.app.client_mode() + if mode != "unset": + raise AssertionError( + f"client latched clientMode={mode!r} against a brain that is " + f"folded but not answering. That is a retry signal, not a " + f"verdict -- the gate must stay unset and be re-probed." + ) + time.sleep(2.0) + ctx.notes.append("gate stayed unset for the whole retry budget, as required") + return + + try: + ctx.app.wait_for_client_mode(expected, timeout=90) + except DriverError as e: + raise AssertionError( + f"{ctx.corner} presents a node the client should read as " + f"{expected}, but {e}" + ) from None + ctx.notes.append(f"clientMode={expected} against {ctx.app.state().get('nodeUrl') or 'the default node'}") + + +def for_corner(corner: str) -> list[Case]: + return [c for c in CASES if corner in c.corners] + + +def skipped_for_corner(corner: str) -> list[Case]: + return [c for c in CASES if corner not in c.corners] diff --git a/testing/driver.py b/testing/driver.py new file mode 100644 index 0000000..6393912 --- /dev/null +++ b/testing/driver.py @@ -0,0 +1,221 @@ +""" +A driver for the client's own TestAutomationServer. + +The SERVER side of this already lives in the client and is ours: +`client/desktopApp/.../testing/TestAutomationServer.kt` (desktop) and the +`TestAutomationServer.{android,ios}.kt` actuals. What did NOT live here was +anything that DRIVES it — that was `tools/test_desktop_wipe_setup.sh` in +CIRISAgent, 191 lines of `curl | grep` against five of the sixteen routes. +This is that driver, taken over and made a library. + +Three things it does that the shell script could not: + +1. **It fails loudly.** The shell script's `get_screen()` was + `curl -s "$TEST_URL/screen"`, and `curl -s` on a dead server prints nothing + and exits 0 — so a run against an app that never started read as a screen + named "" and walked on. Every call here raises `DriverError` with the route, + the status and the body. + +2. **It waits on the right thing.** `wait_for_element` polls `/tree` for the + element, not `sleep 2` and hope. + +3. **It is stdlib-only.** No `requests`, so CI installs nothing to use it. + +Route surface is TestAutomationServer.kt's sixteen; see `TestAutomationServer` +below for the ones we bind. +""" + +from __future__ import annotations + +import json +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from typing import Any + + +class DriverError(RuntimeError): + """A call to the automation server failed, or the app never answered.""" + + +@dataclass +class Element: + """One registered UI element, as `/tree` reports it.""" + + test_tag: str + x: int + y: int + width: int + height: int + text: str | None = None + + @classmethod + def from_json(cls, d: dict[str, Any]) -> "Element": + return cls( + test_tag=d.get("testTag") or d.get("test_tag") or "", + x=int(d.get("x", 0)), + y=int(d.get("y", 0)), + width=int(d.get("width", 0)), + height=int(d.get("height", 0)), + text=d.get("text"), + ) + + +@dataclass +class TestAutomationServer: + """A live connection to one running client's automation server.""" + + base_url: str = "http://127.0.0.1:9091" + timeout: float = 10.0 + trace: list[str] = field(default_factory=list) + + # ---- transport ---------------------------------------------------- + + def _call(self, method: str, route: str, body: dict | None = None) -> Any: + url = f"{self.base_url}{route}" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + if data is not None: + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=self.timeout) as r: + raw = r.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", "replace")[:400] + raise DriverError(f"{method} {route} -> HTTP {e.code}: {detail}") from None + except (urllib.error.URLError, TimeoutError, OSError) as e: + # This is the case `curl -s` swallowed: nothing is listening, which + # means the app is not running. It is never a passing test. + raise DriverError(f"{method} {route} -> no answer from {self.base_url}: {e}") from None + self.trace.append(f"{method} {route}") + if not raw.strip(): + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return raw + + # ---- reads -------------------------------------------------------- + + def health(self) -> dict: + return self._call("GET", "/health") or {} + + def screen(self) -> str: + """The screen the app believes it is showing.""" + r = self._call("GET", "/screen") + if isinstance(r, dict): + return str(r.get("screen") or r.get("currentScreen") or "") + return str(r or "") + + def state(self) -> dict: + """ + The app's own account of its gates: screen, test mode, clientMode, node URL. + + Served by `/state`, added for this harness. The alternative was to infer + node-vs-agent from which widgets are on screen, which asserts the layout + rather than the gate and passes a client that draws agent affordances + against a bare node. + """ + r = self._call("GET", "/state") + return r if isinstance(r, dict) else {} + + def client_mode(self) -> str: + """`NODE`, `AGENT`, or `unset` while the probe is undetermined.""" + return str(self.state().get("clientMode") or "unset") + + def wait_for_client_mode(self, expected: str, timeout: float = 90.0) -> None: + deadline = time.monotonic() + timeout + seen: list[str] = [] + while time.monotonic() < deadline: + cur = self.client_mode() + if cur == expected: + return + if not seen or seen[-1] != cur: + seen.append(cur) + time.sleep(1.0) + raise DriverError( + f"clientMode never became {expected!r} within {timeout:.0f}s " + f"(saw: {' -> '.join(seen) or ''})" + ) + + def tree(self) -> list[Element]: + r = self._call("GET", "/tree") + raw = r.get("elements", []) if isinstance(r, dict) else (r or []) + return [Element.from_json(e) for e in raw if isinstance(e, dict)] + + def element(self, test_tag: str) -> Element | None: + try: + r = self._call("GET", f"/element/{test_tag}") + except DriverError: + return None + return Element.from_json(r) if isinstance(r, dict) and r.get("testTag") else None + + def tags(self) -> set[str]: + return {e.test_tag for e in self.tree()} + + # ---- writes ------------------------------------------------------- + + def click(self, test_tag: str) -> None: + self._call("POST", "/click", {"testTag": test_tag}) + + def input(self, test_tag: str, text: str, clear_first: bool = True) -> None: + self._call("POST", "/input", {"testTag": test_tag, "text": text, "clearFirst": clear_first}) + + def navigate(self, screen: str) -> None: + self._call("POST", "/navigate", {"screen": screen}) + + def act(self, action: str, **kw: Any) -> Any: + return self._call("POST", "/act", {"action": action, **kw}) + + def screenshot(self, path: str) -> bool: + """Ask the app to raise itself and capture. Returns False if it declined.""" + try: + r = self._call("POST", "/screenshot", {"path": path}) + except DriverError: + return False + return bool(r.get("success", True)) if isinstance(r, dict) else True + + # ---- waits -------------------------------------------------------- + + def wait_for_server(self, timeout: float = 90.0) -> None: + """Block until the app's automation server answers at all.""" + deadline = time.monotonic() + timeout + last = "" + while time.monotonic() < deadline: + try: + self.health() + return + except DriverError as e: + last = str(e) + time.sleep(1.0) + raise DriverError(f"automation server never came up within {timeout:.0f}s: {last}") + + def wait_for_element(self, test_tag: str, timeout: float = 60.0) -> Element: + deadline = time.monotonic() + timeout + seen: set[str] = set() + while time.monotonic() < deadline: + for e in self.tree(): + seen.add(e.test_tag) + if e.test_tag == test_tag: + return e + time.sleep(0.5) + near = ", ".join(sorted(seen)[:25]) or "" + raise DriverError( + f"element {test_tag!r} never appeared within {timeout:.0f}s " + f"(screen={self.screen()!r}; registered: {near})" + ) + + def wait_for_screen(self, screen: str, timeout: float = 90.0) -> None: + deadline = time.monotonic() + timeout + seen: list[str] = [] + while time.monotonic() < deadline: + cur = self.screen() + if cur == screen: + return + if not seen or seen[-1] != cur: + seen.append(cur) + time.sleep(0.5) + raise DriverError( + f"screen {screen!r} never reached within {timeout:.0f}s (saw: {' -> '.join(seen) or ''})" + ) diff --git a/testing/node_fixture.py b/testing/node_fixture.py new file mode 100644 index 0000000..2c1b263 --- /dev/null +++ b/testing/node_fixture.py @@ -0,0 +1,391 @@ +""" +Nodes for the client to be a client OF. + +The client is meant to be complete against a node that is LOCAL or REMOTE, and +that is CARRYING A BRAIN or not. Those are two independent axes and the client +resolves them by two different mechanisms, so a harness that only ever points +the app at one auto-started node tests one corner of four and calls it done. + +**Location** — is the mechanism in `PythonRuntime.desktop.startServer()`. The +app probes its configured URL first and only launches `ciris-server` if nothing +answers. So the axis is not "which URL" but "who started the node": + + local nothing pre-booted, `ciris-server` on PATH -> the app launches it, + and the claim-PIN capture path runs for real + remote a node already answering, `CIRIS_API_URL` set -> the app must connect + without launching, and must never try + +**Brain** — is `ClientMode`, derived from `/v1/system/health` (CIRISServer#390): +AGENT iff the node reports a `cognitive_state`, a non-empty service map, or an +answering folded brain; NODE otherwise; and folded-but-not-answering is +UNDETERMINED — a retry signal the client must not latch. + +A real bare node gives us NODE for free: the released binary boots in ~2s and +reports `role: fabric-node`, `agent: {folded:false, reachable:false}`, no +`cognitive_state`. A real AGENT would need a brain and an LLM bill, and it +still could not produce UNDETERMINED on demand — that state is a race. So the +brain axis is served by [BrainFacade]: a proxy in front of the real node that +rewrites ONLY `/v1/system/health`, exactly as the contract documents it, and +passes every other route through to the real node untouched. The client's mode +gate reads the contract; this drives the contract; nothing is stubbed that the +client actually calls. + +ONE CORNER IS NOT REACHABLE HERE, and is not faked: local x agent. The released +node binds 4242/4243 with no port override (`ciris-server [--home ] +[--key-id ]` is the whole usage), so the facade cannot sit where a +self-launched node must be. Folding a real brain onto a local node is the +downstream mobile/manual test; see testing/README.md. +""" + +from __future__ import annotations + +import json +import os +import shutil +import socket +import subprocess +import threading +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +# The released node's fixed ports; see the module docstring. +NODE_LISTEN_PORT = 4242 +NODE_API_PORT = 4243 +LOCAL_NODE_URL = f"http://127.0.0.1:{NODE_API_PORT}" + +# What a folded, answering brain adds to the node's health. Every key here is +# read by `clientModeFrom`; nothing is decorative. +BRAIN_MERGE = { + "cognitive_state": "WORK", + "services": { + f"service_{i:02d}": {"healthy": True, "status": "ok"} for i in range(22) + }, + "agent": {"folded": True, "reachable": True}, +} +# NOTE THE ABSENCE OF `role` (Codex, PR #10). A node's merged health keeps +# `role: "fabric-node"` while a folded brain answers over it -- ClientMode.kt +# says so explicitly, and `clientModeFrom` treats `role == "agent"` as +# CONCLUSIVE, checked before it looks at anything else. Setting it here made +# the agent corner green on that one field alone: the client could stop reading +# cognitive_state, the service map and agent.reachable entirely -- the whole +# folded-node derivation this corner exists for -- and nothing would go red. +# Leaving the real node's role intact forces the verdict through `answeringFold`. + +# Folded but not answering: the UNDETERMINED verdict. No cognitive_state, no +# services -- the two positive signals are absent, so a client that latches +# NODE here has the bug this corner exists to catch. +UNREACHABLE_BRAIN_MERGE = { + "agent": {"folded": True, "reachable": False}, +} + +# A configured brain's setup status. +# +# NOT decoration, and not a shortcut: `clientModeFrom` demotes an answering +# brain to NODE when it reports itself unconfigured (CIRISAgent#1075), and the +# real node behind this facade is a fresh one that says `setup_required: true`. +# A facade that folds a brain onto /v1/system/health and leaves setup saying +# "not set up yet" is presenting an INCOHERENT node, and the correct client +# verdict for it is NODE -- so the agent corner would have failed a blameless +# client. An agent presents both, or it is not an agent. +CONFIGURED_SETUP = { + "setup_required": False, + "has_env_file": True, + "is_first_run": False, + "config_exists": True, +} + +# Per-corner rewrites, keyed by the route they apply to. + +# A bare node that is SET UP (Codex, PR #10). +# +# The setup rewrite is what makes this corner test anything. A fresh node +# reports `setup_required: true`, and `brainUnconfigured` is the FIRST arm of +# `clientModeFrom` -- an unconditional NODE that never reaches the role check, +# the cognitive_state check or the service-count check. So a bare node on a +# fresh home is classified correctly for a reason that bypasses classification, +# and a client that promoted every configured node to AGENT stayed green here. +# The health envelope is left exactly as the real node reports it: this is a +# genuinely bare node that has simply completed its wizard. +NODE_REWRITES = {"/v1/setup/status": CONFIGURED_SETUP} +AGENT_REWRITES = { + "/v1/system/health": BRAIN_MERGE, + "/v1/setup/status": CONFIGURED_SETUP, +} +# Folded, CONFIGURED, and not answering yet. +# +# The setup rewrite is load-bearing, and leaving it out is a mistake this +# harness made and its own run caught. `undetermined` is +# +# agentFolded && !agentReachable && !brainUnconfigured && !declaredAgent +# +# so a facade that folds an unreachable brain onto a node still reporting +# `setup_required: true` is not presenting the undetermined state at all -- it +# is presenting an unconfigured brain, for which NODE is the CORRECT verdict +# (CIRISAgent#1075). The corner failed a blameless client until the node it +# presented was coherent. What this presents now is the real race: the fold +# boots the brain on a daemon thread after the node composes, so a probe can +# legitimately see folded=true/reachable=false on a fully configured home. +UNDETERMINED_REWRITES = { + "/v1/system/health": UNREACHABLE_BRAIN_MERGE, + "/v1/setup/status": CONFIGURED_SETUP, +} + + +def free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return int(s.getsockname()[1]) + + +def _health_ok(url: str, timeout: float = 2.0) -> bool: + try: + with urllib.request.urlopen(f"{url}/v1/system/health", timeout=timeout) as r: + return r.status == 200 + except Exception: + return False + + +def wait_until_up(url: str, timeout: float = 90.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if _health_ok(url): + return + time.sleep(0.5) + raise RuntimeError(f"node at {url} never became healthy within {timeout:.0f}s") + + +def wait_until_down(url: str, timeout: float = 30.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not _health_ok(url, timeout=1.0): + return + time.sleep(0.5) + raise RuntimeError(f"something is still answering at {url} after {timeout:.0f}s") + + +@dataclass +class RealNode: + """A real released `ciris-server`, run against a throwaway home.""" + + binary: str + home: Path + key_id: str = "ciris-client" + proc: subprocess.Popen | None = None + log: Path | None = None + + @property + def url(self) -> str: + return LOCAL_NODE_URL + + def start(self) -> "RealNode": + # REFUSE to adopt a node we did not start. The port is fixed at 4243 and + # a leftover node from an earlier run answers exactly like a fresh one -- + # so without this the suite silently drives a foreign node, with foreign + # state, and goes green. Found by this fixture's own smoke test. + if _health_ok(self.url, timeout=1.5): + raise RuntimeError( + f"something is ALREADY answering at {self.url}. Refusing to start, " + f"because a run against a node this fixture does not own proves " + f"nothing. Stop it first: pkill -f 'ciris-server [-]-home'" + ) + self.home.mkdir(parents=True, exist_ok=True) + self.log = self.home.parent / "node.log" + with open(self.log, "wb") as fh: + self.proc = subprocess.Popen( + [self.binary, "--home", str(self.home), "--key-id", self.key_id], + stdout=fh, + stderr=subprocess.STDOUT, + ) + try: + wait_until_up(self.url) + except RuntimeError: + # KILL IT BEFORE RAISING (Codex, PR #10). A node that starts but + # never goes healthy is still holding the fixed port. Raising from + # here means `start()` never returns, so the caller never assigns + # self.node and its `finally` has nothing to clean up -- the orphan + # then fails every following corner, and outlives the run on a + # developer's machine. + tail = self.tail() + self.stop_process_only() + raise RuntimeError( + f"node did not come up; last log lines:\n{tail}" + ) from None + return self + + def stop_process_only(self) -> None: + """Terminate without waiting on the port -- for a node that never rose.""" + if not self.proc: + return + self.proc.terminate() + try: + self.proc.wait(timeout=15) + except subprocess.TimeoutExpired: + self.proc.kill() + self.proc.wait(timeout=10) + self.proc = None + + def tail(self, n: int = 30) -> str: + if not self.log or not self.log.exists(): + return "" + return "\n".join(self.log.read_text("utf-8", "replace").splitlines()[-n:]) + + def claim_pin(self, timeout: float = 20.0) -> str | None: + """ + The one-time claim PIN, from the node's own home. + + Bounded retry, not a single read: the node writes this file DURING boot, + and health goes green before it lands -- a single read a moment after + startup returns None for a PIN that is about to exist. This is the same + race `PythonRuntime.readLocalClaimPin` retries 20 times to close, and a + fixture that reads once reproduces the bug rather than testing around it. + """ + f = self.home / "claim_pin" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if f.exists(): + pin = f.read_text().strip() + if pin: + return pin + time.sleep(0.5) + return None + + def stop(self) -> None: + if not self.proc: + return + self.proc.terminate() + try: + self.proc.wait(timeout=15) + except subprocess.TimeoutExpired: + self.proc.kill() + self.proc.wait(timeout=10) + self.proc = None + # The next corner binds this port; "terminate() returned" is not the + # same claim as "the listener is gone". + wait_until_down(self.url, timeout=30) + + +class _FacadeHandler(BaseHTTPRequestHandler): + """Proxy everything; rewrite the routes the mode gate reads.""" + + rewrites: dict = {} + upstream: str = LOCAL_NODE_URL + + def log_message(self, fmt: str, *args) -> None: # quiet + pass + + def _proxy(self, method: str) -> None: + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length else None + req = urllib.request.Request(f"{self.upstream}{self.path}", data=body, method=method) + for h in ("Authorization", "Content-Type", "Accept"): + if self.headers.get(h): + req.add_header(h, self.headers[h]) + try: + with urllib.request.urlopen(req, timeout=30) as r: + status, payload = r.status, r.read() + ctype = r.headers.get("Content-Type", "application/json") + except urllib.error.HTTPError as e: + status, payload = e.code, e.read() + ctype = e.headers.get("Content-Type", "application/json") + except Exception as e: + status, payload, ctype = 502, json.dumps({"error": str(e)}).encode(), "application/json" + + # Merged into `data`, leaving every other field the real node reported + # exactly as it reported it. Only the routes the mode gate reads. + merge = next( + (m for route, m in self.rewrites.items() if self.path.startswith(route)), None + ) + if status == 200 and merge: + try: + doc = json.loads(payload) + data = doc.get("data") + if isinstance(data, dict): + data.update(merge) + payload = json.dumps(doc).encode() + except (json.JSONDecodeError, AttributeError): + pass # not JSON we understand; pass the node's own answer through + + self.send_response(status) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def do_GET(self) -> None: + self._proxy("GET") + + def do_POST(self) -> None: + self._proxy("POST") + + def do_PUT(self) -> None: + self._proxy("PUT") + + def do_DELETE(self) -> None: + self._proxy("DELETE") + + +@dataclass +class BrainFacade: + """A remote node URL that presents a chosen brain verdict, coherently.""" + + rewrites: dict + upstream: str = LOCAL_NODE_URL + port: int = 0 + _srv: ThreadingHTTPServer | None = None + _thread: threading.Thread | None = None + + @property + def url(self) -> str: + return f"http://127.0.0.1:{self.port}" + + def start(self) -> "BrainFacade": + self.port = self.port or free_port() + handler = type( + "Handler", (_FacadeHandler,), {"rewrites": self.rewrites, "upstream": self.upstream} + ) + self._srv = ThreadingHTTPServer(("127.0.0.1", self.port), handler) + self._thread = threading.Thread(target=self._srv.serve_forever, daemon=True) + self._thread.start() + wait_until_up(self.url, timeout=20) + return self + + def stop(self) -> None: + if self._srv: + self._srv.shutdown() + self._srv.server_close() + self._srv = None + + +def find_node_binary(explicit: str | None = None) -> str: + """ + The released `ciris-server`, wherever CI or a developer put it. + + An explicit choice that does not exist is an ERROR, never a fallback. There + is more than one thing called `ciris-server` in this ecosystem -- the Rust + node this client drives, and a Python console script with a different CLI -- + so silently resolving a bad `--node-bin` to whatever is on PATH runs the + whole suite against the wrong program and reports on it as if it were right. + Caught by this harness's own mutation test. + """ + for label, cand in (("--node-bin", explicit), ("CIRIS_SERVER_BIN", os.environ.get("CIRIS_SERVER_BIN"))): + if cand: + if Path(cand).is_file(): + return str(Path(cand).resolve()) + raise RuntimeError( + f"{label}={cand!r} does not exist. Refusing to fall back to " + f"PATH: the wrong `ciris-server` would run the whole suite and " + f"report on it as though it were the right one." + ) + found = shutil.which("ciris-server") + if found: + return found + raise RuntimeError( + "no `ciris-server` found. Download one from the CIRISServer releases:\n" + " gh release download -R CIRISAI/CIRISServer \\\n" + " -p 'ciris-server--x86_64-unknown-linux-gnu.tar.gz'\n" + "then set CIRIS_SERVER_BIN to the extracted binary, or put it on PATH." + ) diff --git a/testing/run_e2e.py b/testing/run_e2e.py new file mode 100644 index 0000000..9cdbcf3 --- /dev/null +++ b/testing/run_e2e.py @@ -0,0 +1,546 @@ +""" +The desktop end-to-end runner. + + python3 -m testing.run_e2e --corner all --jar --report out.json + +For each corner it stands up the node the corner describes, launches the real +desktop app against it under a real display, drives it through the automation +server, and writes a machine-readable report. + +WHY THIS EXISTS AT ALL: until now CIRISClient's CI compiled the client, unit +tested `:shared`, built a jar and a wheel -- and never once started the app. +Every defect that reached the server team (a Reset that exits instead of +returning to setup, an Android element that registers a handler but not itself, +a debug export written where no file manager can see it) is a defect that only +exists once the app is RUNNING. This is the missing half. + +HOW IT FAILS: loudly and with evidence. On any case failure it captures a +screenshot through `/screenshot`, dumps the element tree and the node log into +the report, and exits non-zero. A corner that could not be stood up is an +ERROR, never a skip -- the one thing a test harness must never do is decline to +run and report success. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import time +import traceback +from dataclasses import asdict, dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from testing import cases as case_mod # noqa: E402 +from testing.cases import ( # noqa: E402 + ALL_CORNERS, + LOCAL_NODE, + REMOTE_AGENT, + REMOTE_NODE, + REMOTE_UNDETERMINED, + Context, +) +from testing.driver import DriverError, TestAutomationServer # noqa: E402 +from testing.node_fixture import ( # noqa: E402 + AGENT_REWRITES, + LOCAL_NODE_URL, + NODE_API_PORT, + NODE_REWRITES, + UNDETERMINED_REWRITES, + BrainFacade, + RealNode, + _health_ok, + find_node_binary, + free_port, + wait_until_down, +) + +TEST_PORT_DEFAULT = 9091 + + +@dataclass +class CaseResult: + name: str + status: str # passed | failed | skipped + detail: str = "" + notes: list[str] = field(default_factory=list) + seconds: float = 0.0 + + +@dataclass +class CornerResult: + corner: str + status: str # passed | failed | error + cases: list[CaseResult] = field(default_factory=list) + screen: str = "" + #: The app's own /state at the end of the corner -- clientMode and node URL. + #: Evidence, and the thing a later compatibility question + #: will actually be asked about. + app_state: dict = field(default_factory=dict) + elements: list[str] = field(default_factory=list) + node_log_tail: str = "" + app_log_tail: str = "" + screenshot: str = "" + error: str = "" + seconds: float = 0.0 + + +def _pids_on_port(port: int) -> list[int]: + """PIDs listening on a local TCP port, via /proc -- no lsof dependency.""" + inodes = set() + for tcp in ("/proc/net/tcp", "/proc/net/tcp6"): + try: + lines = Path(tcp).read_text().splitlines()[1:] + except OSError: + continue + for line in lines: + f = line.split() + if len(f) < 10 or f[3] != "0A": # 0A = LISTEN + continue + try: + if int(f[1].split(":")[1], 16) == port: + inodes.add(f[9]) + except (ValueError, IndexError): + continue + if not inodes: + return [] + pids = [] + for entry in Path("/proc").iterdir(): + if not entry.name.isdigit(): + continue + try: + for fd in (entry / "fd").iterdir(): + target = os.readlink(fd) + if target.startswith("socket:[") and target[8:-1] in inodes: + pids.append(int(entry.name)) + break + except (OSError, PermissionError): + continue + return pids + + +def _client_version() -> str: + v = Path(__file__).resolve().parent.parent / "VERSION" + return v.read_text().strip() if v.is_file() else "" + + +def _port_busy(port: int) -> bool: + with socket.socket() as s: + s.settimeout(0.5) + return s.connect_ex(("127.0.0.1", port)) == 0 + + +def _wrap_display(cmd: list[str]) -> list[str]: + """Give the app a display. CI has no X server; xvfb-run supplies one.""" + if os.environ.get("DISPLAY"): + return cmd + xvfb = shutil.which("xvfb-run") + if not xvfb: + raise RuntimeError( + "no DISPLAY and no xvfb-run. The desktop app needs a display; " + "install xvfb (`sudo apt-get install -y xvfb`) or run under one." + ) + # -a picks a free server number; the screen must be big enough that the + # window is not clipped, or elements register at coordinates off-screen. + return [xvfb, "-a", "-s", "-screen 0 1920x1200x24"] + cmd + + + +def _ciris_server_pids() -> list[int]: + """Every live `ciris-server`, by PID. /proc only -- no psutil dependency.""" + out = [] + for entry in Path("/proc").iterdir(): + if not entry.name.isdigit(): + continue + try: + cmd = (entry / "cmdline").read_bytes().replace(b"\0", b" ").decode("utf-8", "replace") + except (OSError, PermissionError): + continue + if "ciris-server" in cmd and "--home" in cmd: + out.append(int(entry.name)) + return out + + +# The app's own record of the spawn. Polling for a live process is racy in +# exactly the case that matters: a node binary that dies on startup exists for +# less than one poll interval, so a client that DID try to launch one reads as a +# client that never tried. The log line is durable evidence of the same event. +NODE_LAUNCH_MARKER = "Started ciris-server (PID:" + + +def app_logged_a_node_launch(app_log: Path) -> bool: + if not app_log.exists(): + return False + return NODE_LAUNCH_MARKER in app_log.read_text("utf-8", "replace") + + +def app_spawned_a_node(app_proc: subprocess.Popen | None, app_log: Path | None = None) -> bool: + """ + Did the APP start a node, as opposed to one merely existing? + + "A node is answering on the local port" cannot answer this: the remote + corners deliberately run a real node as the substrate behind the facade, so + that check was true for a client that did nothing wrong. This asks the + precise question instead. The app is launched with `start_new_session=True`, + so anything it spawns -- through xvfb-run, through the JVM, at any depth -- + inherits its session id, and nothing else on the machine shares it. + """ + if app_log is not None and app_logged_a_node_launch(app_log): + return True + if not app_proc: + return False + try: + app_sid = os.getsid(app_proc.pid) + except (ProcessLookupError, PermissionError): + return False + for pid in _ciris_server_pids(): + try: + if os.getsid(pid) == app_sid: + return True + except (ProcessLookupError, PermissionError): + continue + return False + + +class Corner: + """One row of the matrix, stood up and torn down.""" + + def __init__(self, name: str, jar: Path, node_bin: str, test_port: int, workdir: Path): + self.name = name + self.jar = jar + self.node_bin = node_bin + self.test_port = test_port + self.workdir = workdir + self.node: RealNode | None = None + self.facade: BrainFacade | None = None + self.app: subprocess.Popen | None = None + self.app_log = workdir / "app.log" + self.node_home = workdir / "nodehome" + self.api_url: str | None = None + + # -- setup --------------------------------------------------------- + + def start_nodes(self) -> None: + if self.name == LOCAL_NODE: + # Nothing pre-started ON PURPOSE: the app must launch it, and that + # launch is the thing under test. + if _health_ok(LOCAL_NODE_URL, timeout=1.5): + raise RuntimeError( + f"something is already answering at {LOCAL_NODE_URL}; the " + f"local corner cannot test self-launch against it. " + f"Stop it first: pkill -f 'ciris-server [-]-home'" + ) + return + + # Remote corners need a real node behind the facade. + self.node = RealNode(self.node_bin, self.node_home).start() + rewrites = { + REMOTE_NODE: NODE_REWRITES, + REMOTE_AGENT: AGENT_REWRITES, + REMOTE_UNDETERMINED: UNDETERMINED_REWRITES, + }[self.name] + self.facade = BrainFacade(rewrites, upstream=self.node.url).start() + self.api_url = self.facade.url + + def app_env(self) -> dict[str, str]: + env = dict(os.environ) + env["CIRIS_TEST_MODE"] = "true" + env["CIRIS_TEST_PORT"] = str(self.test_port) + env["CIRIS_HOME"] = str(self.workdir / "cirishome") + env["HOME"] = str(self.workdir / "fakehome") + Path(env["CIRIS_HOME"]).mkdir(parents=True, exist_ok=True) + Path(env["HOME"]).mkdir(parents=True, exist_ok=True) + # The node binary is on PATH in EVERY corner (Codex, PR #10). + # + # It reads like it belongs only to the local corner, and putting it + # there made `did_not_launch_a_node` vacuous: on a CI runner there is no + # `ciris-server` on PATH at all, so a client that DOES launch a local + # node whenever it can find one -- the actual regression, and the common + # developer setup where a node is installed -- had no way to exhibit it. + # The corners now differ by CIRIS_API_URL alone, which is the real + # difference, and the negative is a negative. + env["PATH"] = f"{Path(self.node_bin).parent}{os.pathsep}{env.get('PATH','')}" + if self.api_url: + # Both spellings: CIRIS_NODE_URL is upstream's, CIRIS_API_URL is ours. + env["CIRIS_API_URL"] = self.api_url + env["CIRIS_NODE_URL"] = self.api_url + else: + env.pop("CIRIS_API_URL", None) + env.pop("CIRIS_NODE_URL", None) + return env + + def start_app(self) -> None: + cmd = _wrap_display(["java", "-jar", str(self.jar)]) + with open(self.app_log, "wb") as fh: + # Its own session, so we can signal the WHOLE tree. Under xvfb-run + # the JVM is a grandchild; terminating the wrapper alone leaves it + # holding the test port, and the next run then (correctly) refuses + # to start against an app it does not own. + self.app = subprocess.Popen( + cmd, env=self.app_env(), stdout=fh, stderr=subprocess.STDOUT, + start_new_session=True, + ) + + # -- teardown ------------------------------------------------------ + + def stop(self) -> None: + if self.app: + self._signal_app_group(signal.SIGTERM) + try: + self.app.wait(timeout=20) + except subprocess.TimeoutExpired: + self._signal_app_group(signal.SIGKILL) + self.app.wait(timeout=10) + self.app = None + # Prove the port is free. "I sent SIGTERM" is not that claim, and + # the next corner binds this port. + deadline = time.monotonic() + 30 + while time.monotonic() < deadline and _port_busy(self.test_port): + time.sleep(0.5) + if self.facade: + self.facade.stop() + self.facade = None + if self.node: + self.node.stop() + self.node = None + # The local corner's node was started by the APP; it is our job to see + # it gone before the next corner claims the port. + if _health_ok(LOCAL_NODE_URL, timeout=1.5): + # The bracket keeps the pattern from matching the shell that runs + # it -- `pkill -f 'ciris-server --home'` matches its own command + # line and kills the caller. Cost me one silent exit-144 run. + subprocess.run(["pkill", "-f", "ciris-server [-]-home"], check=False) + try: + wait_until_down(LOCAL_NODE_URL, timeout=30) + except RuntimeError: + pass + + def _discover_node_home(self) -> Path | None: + """Where the APP told its node to live -- read off the node's cmdline.""" + for pid in _ciris_server_pids(): + try: + if os.getsid(pid) != os.getsid(self.app.pid): # type: ignore[union-attr] + continue + parts = (Path("/proc") / str(pid) / "cmdline").read_bytes().split(b"\0") + args = [a.decode("utf-8", "replace") for a in parts if a] + if "--home" in args: + return Path(args[args.index("--home") + 1]) + except (OSError, PermissionError, ProcessLookupError, IndexError, AttributeError): + continue + return None + + def _signal_app_group(self, sig: int) -> None: + if not self.app: + return + try: + os.killpg(os.getpgid(self.app.pid), sig) + except (ProcessLookupError, PermissionError): + try: + self.app.send_signal(sig) + except ProcessLookupError: + pass + + def tail(self, p: Path, n: int = 40) -> str: + if not p.exists(): + return "" + return "\n".join(p.read_text("utf-8", "replace").splitlines()[-n:]) + + # -- run ----------------------------------------------------------- + + def run(self) -> CornerResult: + started = time.monotonic() + res = CornerResult(corner=self.name, status="passed") + self.workdir.mkdir(parents=True, exist_ok=True) + try: + self.start_nodes() + self.start_app() + app = TestAutomationServer(f"http://127.0.0.1:{self.test_port}") + try: + app.wait_for_server(timeout=180) + except DriverError as e: + raise RuntimeError( + f"the app never exposed its automation server. " + f"{e}\n--- app log ---\n{self.tail(self.app_log)}" + ) from None + + ctx = Context( + corner=self.name, + app=app, + node_url=self.api_url or LOCAL_NODE_URL, + node_was_prestarted=self.node is not None, + node_home=self.node_home if self.name == LOCAL_NODE else None, + ) + if self.name == LOCAL_NODE: + # The app launches its node lazily; give it the same window the + # client's own startServer() health loop uses before deciding + # it never happened. + deadline = time.monotonic() + 90 + while time.monotonic() < deadline and not app_spawned_a_node(self.app, self.app_log): + time.sleep(1.0) + # It writes its home under the app's CIRIS_HOME, not ours. + ctx.node_home = self._discover_node_home() or ctx.node_home + + for c in case_mod.for_corner(self.name): + t0 = time.monotonic() + # Re-read each time: the local corner's answer changes as the + # app gets around to launching its node. + ctx.app_spawned_node = app_spawned_a_node(self.app, self.app_log) + before = list(ctx.notes) + try: + c.fn(ctx) + res.cases.append( + CaseResult(c.name, "passed", + notes=[n for n in ctx.notes if n not in before], + seconds=round(time.monotonic() - t0, 2)) + ) + except Exception as e: + res.status = "failed" + res.cases.append( + CaseResult(c.name, "failed", detail=str(e), + notes=[n for n in ctx.notes if n not in before], + seconds=round(time.monotonic() - t0, 2)) + ) + + for c in case_mod.skipped_for_corner(self.name): + res.cases.append(CaseResult(c.name, "skipped", detail=f"not applicable to {self.name}")) + + res.screen = app.screen() + res.app_state = app.state() + res.elements = sorted(app.tags()) + if res.status == "failed": + shot = self.workdir / "failure.png" + if app.screenshot(str(shot)) and shot.exists(): + res.screenshot = str(shot) + except Exception as e: + res.status = "error" + res.error = f"{e}\n{traceback.format_exc(limit=3)}" + finally: + if self.node: + res.node_log_tail = self.node.tail() + res.app_log_tail = self.tail(self.app_log) + self.stop() + res.seconds = round(time.monotonic() - started, 1) + return res + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--corner", default="all", + help=f"one of {', '.join(ALL_CORNERS)}, or 'all'") + ap.add_argument("--jar", required=True, help="the desktop uber-jar") + ap.add_argument("--node-bin", default=None, help="released ciris-server binary") + ap.add_argument("--test-port", type=int, default=TEST_PORT_DEFAULT) + ap.add_argument("--report", default=None, help="write a JSON report here") + ap.add_argument("--node-version", default=None, + help="the CIRISServer tag this node came from; recorded in " + "the report so the evidence identifies its pairing") + ap.add_argument("--workdir", default=None) + ap.add_argument("--reclaim", action="store_true", + help="kill a leftover TEST-MODE app holding the test port " + "(safe: a test-mode app is a previous run's artefact)") + args = ap.parse_args() + + jar = Path(args.jar).resolve() + if not jar.is_file(): + print(f"no such jar: {jar}", file=sys.stderr) + return 2 + node_bin = find_node_binary(args.node_bin) + corners = list(ALL_CORNERS) if args.corner == "all" else [args.corner] + unknown = [c for c in corners if c not in ALL_CORNERS] + if unknown: + print(f"unknown corner(s): {', '.join(unknown)}", file=sys.stderr) + return 2 + + root = Path(args.workdir) if args.workdir else Path(tempfile.mkdtemp(prefix="ciris-e2e-")) + print(f"jar : {jar}") + print(f"node : {node_bin}") + print(f"workdir : {root}") + print(f"corners : {', '.join(corners)}") + print(f"pairing : client {_client_version()} against node " + f"{args.node_version or ''}\n") + + if _port_busy(args.test_port) and args.reclaim: + # Only ever a TEST-MODE CIRIS app: that is a disposable artefact of a + # previous run by construction, and killing it is safe. Anything else + # on the port still stops the run. + probe = TestAutomationServer(f"http://127.0.0.1:{args.test_port}") + try: + if probe.health().get("testMode") is True: + for pid in _pids_on_port(args.test_port): + try: + os.killpg(os.getpgid(pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + deadline = time.monotonic() + 20 + while time.monotonic() < deadline and _port_busy(args.test_port): + time.sleep(0.5) + print(f"reclaimed port {args.test_port} from a leftover test-mode app") + except DriverError: + pass + + if _port_busy(args.test_port): + who = "" + try: + who = f" It answers /screen with {TestAutomationServer(f'http://127.0.0.1:{args.test_port}').screen()!r}, so it is a CIRIS app -- most likely one a previous run left behind." + except DriverError: + who = " It is not answering as a CIRIS automation server." + print( + f"port {args.test_port} is busy; the driver would drive the wrong app.{who}\n" + f"Either stop it, or pass --test-port with a free one.", + file=sys.stderr, + ) + return 2 + + results: list[CornerResult] = [] + for name in corners: + print(f"=== {name} ".ljust(70, "=") + "\n") + r = Corner(name, jar, node_bin, args.test_port, root / name).run() + results.append(r) + for c in r.cases: + if c.status == "skipped": + continue + mark = "PASS" if c.status == "passed" else "FAIL" + print(f" [{mark}] {c.name} ({c.seconds}s)") + for n in c.notes: + print(f" - {n}") + if c.detail and c.status == "failed": + print(f" ! {c.detail}") + n_skip = sum(1 for c in r.cases if c.status == "skipped") + if n_skip: + print(f" ({n_skip} case(s) not applicable to this corner)") + if r.status == "error": + print(f" [ERROR] {r.error.splitlines()[0] if r.error else '?'}") + print(f" --- app log ---\n{r.app_log_tail}") + print(f" -> {r.status.upper()} in {r.seconds}s\n") + + if args.report: + # WHICH PAIRING PRODUCED THIS (Codex, PR #10). The client can cut a + # version before the server tags the matching one, so CI falls back to + # the latest release -- and evidence that cannot say which node it ran + # against cannot be used to settle a compatibility question later. + Path(args.report).write_text(json.dumps({ + "client_version": _client_version(), + "node_version": args.node_version or "", + "node_binary": node_bin, + "corners": [asdict(r) for r in results], + }, indent=2)) + print(f"report: {args.report}") + + bad = [r for r in results if r.status != "passed"] + print("\n" + "=" * 70) + for r in results: + print(f" {r.corner:24s} {r.status}") + print("=" * 70) + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main())