diff --git a/apps/landing/public/_worker.js b/apps/landing/public/_worker.js index 7c6fb5bc..14d10260 100644 --- a/apps/landing/public/_worker.js +++ b/apps/landing/public/_worker.js @@ -1,3 +1,56 @@ +// snapotter.com needs no probe: this worker answering the request is the proof +// it is up. Only the two sibling properties are checked. +const STATUS_PROBES = ["https://demo.snapotter.com/", "https://docs.snapotter.com/"]; + +// Probed at the edge rather than read from the Sentry uptime API: no standing +// credential on a public worker, and no 15-minute lag from Sentry's 5-minute +// interval times its 3-strike rule. +// +// One immediate retry absorbs a transient blip. `redirect: "manual"` stops the +// follow, so a 3xx arrives as-is and still counts as up. +async function probe(url) { + for (let attempt = 0; attempt < 2; attempt++) { + // Built outside the try, so a throw here surfaces as the runtime bug it is + // rather than being read as a down leg. Built inside the loop, so each + // attempt carries its own deadline: hoisting it above the loop would leave + // attempt 2 holding an already-fired signal, silently deleting the retry + // for the exact transient-blip case the retry exists to absorb. + const signal = AbortSignal.timeout(2000); + let res; + try { + res = await fetch(url, { method: "HEAD", redirect: "manual", signal }); + } catch { + // A throw is a timeout or a network error, which is a legitimate "down" + // signal rather than a swallowed bug. Retry once, then report the leg + // down. Only the fetch is guarded, so nothing below can be swallowed. + continue; + } + if (res.status < 400) return true; + } + return false; +} + +async function statusResponse() { + const legs = await Promise.all(STATUS_PROBES.map((url) => probe(url))); + const downCount = legs.filter((up) => !up).length; + const status = downCount === 0 ? "operational" : downCount === legs.length ? "down" : "partial"; + + // A false green is cheap to sit on for a minute; a false red is not, and it + // would otherwise pin in the browser across every navigation until it aged + // out. Recheck a bad verdict sooner. + const maxAge = status === "operational" ? 60 : 15; + + // `_headers` only decorates env.ASSETS responses in advanced mode, so a + // synthesized response has to carry its own. + return new Response(JSON.stringify({ status }), { + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": `public, max-age=${maxAge}`, + "X-Robots-Tag": "noindex", + }, + }); +} + export default { async fetch(request, env) { const url = new URL(request.url); @@ -7,6 +60,10 @@ export default { return Response.redirect(url.toString(), 301); } + if (url.pathname === "/api/status") { + return statusResponse(); + } + const response = await env.ASSETS.fetch(request); if (url.pathname.startsWith("/_next/static/") || url.pathname.startsWith("/_next/data/")) { diff --git a/apps/landing/src/components/Footer.astro b/apps/landing/src/components/Footer.astro index ec7c729d..f94c1998 100644 --- a/apps/landing/src/components/Footer.astro +++ b/apps/landing/src/components/Footer.astro @@ -2,6 +2,7 @@ // biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports. // biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values. import LanguageSwitcher from "@/components/LanguageSwitcher.astro"; +import StatusIndicator from "@/components/StatusIndicator.astro"; import { t } from "@/i18n"; import { enOnlyHref, localizeHref } from "@/lib/i18n-page"; @@ -156,6 +157,8 @@ const columns = [ {t(locale, "footer.copyright", { year })}

+ +

contact@snapotter.com

diff --git a/apps/landing/src/components/StatusIndicator.astro b/apps/landing/src/components/StatusIndicator.astro new file mode 100644 index 00000000..579c4a57 --- /dev/null +++ b/apps/landing/src/components/StatusIndicator.astro @@ -0,0 +1,87 @@ +--- +// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values. +import { t } from "@/i18n"; + +// Required rather than optional-with-an-"en"-default: an omitted prop would +// typecheck, lint, render, and quietly serve English on all 20 localized page +// trees. LanguageSwitcher, three lines away in the same footer cluster, +// declares locale the same way. +interface Props { + locale: string; +} +const { locale } = Astro.props; + +// Every label is resolved server-side and handed to the script, so the client +// never needs a second request or a translation table of its own. +const labels = { + checking: t(locale, "footer.status.checking"), + operational: t(locale, "footer.status.operational"), + partial: t(locale, "footer.status.partial"), + down: t(locale, "footer.status.down"), +}; +--- + + + + {labels.checking} + + + + + diff --git a/apps/landing/src/i18n/ar.json b/apps/landing/src/i18n/ar.json index ab86b9ed..6e658e9e 100644 --- a/apps/landing/src/i18n/ar.json +++ b/apps/landing/src/i18n/ar.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "الرعاة", "footer.link.terms": "الشروط", "footer.link.videoTools": "أدوات الفيديو", + "footer.status.checking": "جارٍ التحقق من الحالة", + "footer.status.down": "انقطاع في الخدمة", + "footer.status.operational": "جميع الأنظمة تعمل", + "footer.status.partial": "انقطاع جزئي", "footer.tagline": "معالجة ملفات ذاتية الاستضافة. ملفاتك، بنيتك التحتية.", "home.categoryCards.audio.blurb": "حوّل وقُص وانسخ نصيًا", "home.categoryCards.audio.label": "أدوات الصوت", diff --git a/apps/landing/src/i18n/ar.meta.json b/apps/landing/src/i18n/ar.meta.json index 4ac98812..9c400ded 100644 --- a/apps/landing/src/i18n/ar.meta.json +++ b/apps/landing/src/i18n/ar.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "256d93f3139d", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "b017d78ed0a9", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "e5e2ee0d501d", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "d20bd747d369", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "9d7b2ae83bbb", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/de.json b/apps/landing/src/i18n/de.json index 19640cc4..b97995d0 100644 --- a/apps/landing/src/i18n/de.json +++ b/apps/landing/src/i18n/de.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "Sponsoren", "footer.link.terms": "AGB", "footer.link.videoTools": "Video-Tools", + "footer.status.checking": "Status wird geprüft", + "footer.status.down": "Betriebsstörung", + "footer.status.operational": "Alle Systeme betriebsbereit", + "footer.status.partial": "Teilweise Störung", "footer.tagline": "Selbst gehostete Dateiverarbeitung. Deine Dateien, deine Infrastruktur.", "home.categoryCards.audio.blurb": "Konvertieren, schneiden und transkribieren", "home.categoryCards.audio.label": "Audio-Tools", diff --git a/apps/landing/src/i18n/de.meta.json b/apps/landing/src/i18n/de.meta.json index 96a7db42..2e24f3dd 100644 --- a/apps/landing/src/i18n/de.meta.json +++ b/apps/landing/src/i18n/de.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "a502215d5613", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "88b330c6150a", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "04328d48c0a3", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "e40909e11e26", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "0113e86fcbec", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/en.json b/apps/landing/src/i18n/en.json index a7967453..140e11fe 100644 --- a/apps/landing/src/i18n/en.json +++ b/apps/landing/src/i18n/en.json @@ -208,6 +208,10 @@ "terms.contact.title": "Contact", "terms.contact.pre": "If you have questions about these terms, contact us at", "footer.tagline": "Self-hosted file processing. Your files, your infrastructure.", + "footer.status.checking": "Checking status", + "footer.status.operational": "All systems operational", + "footer.status.partial": "Partial outage", + "footer.status.down": "Service disruption", "footer.col.product": "Product", "footer.col.solutions": "Solutions", "footer.col.resources": "Resources", diff --git a/apps/landing/src/i18n/es.json b/apps/landing/src/i18n/es.json index f17b5721..eaa48bc0 100644 --- a/apps/landing/src/i18n/es.json +++ b/apps/landing/src/i18n/es.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "Patrocinadores", "footer.link.terms": "Términos", "footer.link.videoTools": "Herramientas de vídeo", + "footer.status.checking": "Comprobando el estado", + "footer.status.down": "Interrupción del servicio", + "footer.status.operational": "Todos los sistemas operativos", + "footer.status.partial": "Interrupción parcial", "footer.tagline": "Procesamiento de archivos autoalojado. Tus archivos, tu infraestructura.", "home.categoryCards.audio.blurb": "Convierte, recorta y transcribe", "home.categoryCards.audio.label": "Herramientas de audio", diff --git a/apps/landing/src/i18n/es.meta.json b/apps/landing/src/i18n/es.meta.json index 5ad56628..71fd8656 100644 --- a/apps/landing/src/i18n/es.meta.json +++ b/apps/landing/src/i18n/es.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "2d072a23e553", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "d32b32586b62", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "c4a2ba37ea1c", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "f27b36cd7197", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "a4f51d3ff14a", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/fr.json b/apps/landing/src/i18n/fr.json index f4fcbacb..dd711ace 100644 --- a/apps/landing/src/i18n/fr.json +++ b/apps/landing/src/i18n/fr.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "Sponsors", "footer.link.terms": "Conditions", "footer.link.videoTools": "Outils vidéo", + "footer.status.checking": "Vérification de l'état", + "footer.status.down": "Interruption de service", + "footer.status.operational": "Tous les systèmes sont opérationnels", + "footer.status.partial": "Panne partielle", "footer.tagline": "Traitement de fichiers auto-hébergé. Vos fichiers, votre infrastructure.", "home.categoryCards.audio.blurb": "Convertir, découper et transcrire", "home.categoryCards.audio.label": "Outils audio", diff --git a/apps/landing/src/i18n/fr.meta.json b/apps/landing/src/i18n/fr.meta.json index faddfe1c..b089702a 100644 --- a/apps/landing/src/i18n/fr.meta.json +++ b/apps/landing/src/i18n/fr.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "79b73471a4c0", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "301797ab5747", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "31fe4322d704", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "d85e9b29c782", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "e3cadcd85e3f", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/hi.json b/apps/landing/src/i18n/hi.json index 08591b95..65f4ed4c 100644 --- a/apps/landing/src/i18n/hi.json +++ b/apps/landing/src/i18n/hi.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "स्पॉन्सर", "footer.link.terms": "नियम", "footer.link.videoTools": "वीडियो टूल्स", + "footer.status.checking": "स्थिति जाँची जा रही है", + "footer.status.down": "सेवा में व्यवधान", + "footer.status.operational": "सभी सिस्टम चालू हैं", + "footer.status.partial": "आंशिक व्यवधान", "footer.tagline": "सेल्फ-होस्टेड फ़ाइल प्रोसेसिंग। आपकी फ़ाइलें, आपका इंफ़्रास्ट्रक्चर।", "home.categoryCards.audio.blurb": "कन्वर्ट करें, ट्रिम करें और ट्रांसक्राइब करें", "home.categoryCards.audio.label": "ऑडियो टूल्स", diff --git a/apps/landing/src/i18n/hi.meta.json b/apps/landing/src/i18n/hi.meta.json index 11518022..0d5ab0c7 100644 --- a/apps/landing/src/i18n/hi.meta.json +++ b/apps/landing/src/i18n/hi.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "872b9cdef397", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "cd4ab3cacf37", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "c02bb8cefe76", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "f453462e3c7c", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "7c3d9cf3fd6f", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/id.json b/apps/landing/src/i18n/id.json index 1e162b1c..d5bb21bf 100644 --- a/apps/landing/src/i18n/id.json +++ b/apps/landing/src/i18n/id.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "Sponsor", "footer.link.terms": "Ketentuan", "footer.link.videoTools": "Alat Video", + "footer.status.checking": "Memeriksa status", + "footer.status.down": "Gangguan layanan", + "footer.status.operational": "Semua sistem beroperasi", + "footer.status.partial": "Gangguan sebagian", "footer.tagline": "Pemrosesan file yang di-host sendiri. File Anda, infrastruktur Anda.", "home.categoryCards.audio.blurb": "Konversi, pangkas, dan transkrip", "home.categoryCards.audio.label": "Alat Audio", diff --git a/apps/landing/src/i18n/id.meta.json b/apps/landing/src/i18n/id.meta.json index bd5317ec..d7c111e5 100644 --- a/apps/landing/src/i18n/id.meta.json +++ b/apps/landing/src/i18n/id.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "dec57d480cf0", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "b73b3dd1f0d2", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "5c5fd19fba20", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "cf6583627ce3", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "31de9dede8be", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/it.json b/apps/landing/src/i18n/it.json index e7bc9ba1..58df6d16 100644 --- a/apps/landing/src/i18n/it.json +++ b/apps/landing/src/i18n/it.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "Sponsor", "footer.link.terms": "Termini", "footer.link.videoTools": "Strumenti per video", + "footer.status.checking": "Verifica dello stato", + "footer.status.down": "Interruzione del servizio", + "footer.status.operational": "Tutti i sistemi operativi", + "footer.status.partial": "Interruzione parziale", "footer.tagline": "Elaborazione dei file self-hosted. I tuoi file, la tua infrastruttura.", "home.categoryCards.audio.blurb": "Converti, taglia e trascrivi", "home.categoryCards.audio.label": "Strumenti per audio", diff --git a/apps/landing/src/i18n/it.meta.json b/apps/landing/src/i18n/it.meta.json index b5236b3b..c31ab9c7 100644 --- a/apps/landing/src/i18n/it.meta.json +++ b/apps/landing/src/i18n/it.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "33815b0d0dbc", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "2e624449a048", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "1709c7763a02", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "c621b714e7ca", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "3bbdc1feee81", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/ja.json b/apps/landing/src/i18n/ja.json index 33cd4189..9bdb52be 100644 --- a/apps/landing/src/i18n/ja.json +++ b/apps/landing/src/i18n/ja.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "スポンサー", "footer.link.terms": "利用規約", "footer.link.videoTools": "動画ツール", + "footer.status.checking": "ステータスを確認中", + "footer.status.down": "サービス障害", + "footer.status.operational": "全システム正常稼働中", + "footer.status.partial": "一部システムで障害", "footer.tagline": "セルフホスト型のファイル処理。あなたのファイルは、あなたのインフラで。", "home.categoryCards.audio.blurb": "変換、トリミング、文字起こし", "home.categoryCards.audio.label": "音声ツール", diff --git a/apps/landing/src/i18n/ja.meta.json b/apps/landing/src/i18n/ja.meta.json index 7c4f6ff3..bbf72029 100644 --- a/apps/landing/src/i18n/ja.meta.json +++ b/apps/landing/src/i18n/ja.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "4754b9af9b4e", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "993f0f834f79", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "413c8ab1c0d7", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "4be5062794d7", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "2d30a3f815fa", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/ko.json b/apps/landing/src/i18n/ko.json index 3ee760e0..6a1f546d 100644 --- a/apps/landing/src/i18n/ko.json +++ b/apps/landing/src/i18n/ko.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "후원자", "footer.link.terms": "약관", "footer.link.videoTools": "동영상 도구", + "footer.status.checking": "상태 확인 중", + "footer.status.down": "서비스 장애", + "footer.status.operational": "모든 시스템 정상", + "footer.status.partial": "일부 서비스 장애", "footer.tagline": "셀프 호스팅 파일 처리. 여러분의 파일, 여러분의 인프라.", "home.categoryCards.audio.blurb": "변환, 자르기, 전사", "home.categoryCards.audio.label": "오디오 도구", diff --git a/apps/landing/src/i18n/ko.meta.json b/apps/landing/src/i18n/ko.meta.json index 7a5092f8..245c59d0 100644 --- a/apps/landing/src/i18n/ko.meta.json +++ b/apps/landing/src/i18n/ko.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "d53b28e2d833", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "a8bb5f84b6b3", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "16604bec3746", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "d521ab3858fe", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "8e5fe4c37a54", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/nl.json b/apps/landing/src/i18n/nl.json index c9cc269e..bfc2123d 100644 --- a/apps/landing/src/i18n/nl.json +++ b/apps/landing/src/i18n/nl.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "Sponsors", "footer.link.terms": "Voorwaarden", "footer.link.videoTools": "Videotools", + "footer.status.checking": "Status wordt gecontroleerd", + "footer.status.down": "Servicestoring", + "footer.status.operational": "Alle systemen operationeel", + "footer.status.partial": "Gedeeltelijke storing", "footer.tagline": "Zelf-gehoste bestandsverwerking. Jouw bestanden, jouw infrastructuur.", "home.categoryCards.audio.blurb": "Converteren, inkorten en transcriberen", "home.categoryCards.audio.label": "Audiotools", diff --git a/apps/landing/src/i18n/nl.meta.json b/apps/landing/src/i18n/nl.meta.json index a6886fbf..2011a795 100644 --- a/apps/landing/src/i18n/nl.meta.json +++ b/apps/landing/src/i18n/nl.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "e30f0ffd40ec", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "52cfcddd2d4e", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "3fbde2c6f5e6", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "e3841d8bf881", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "a9a167bcbe19", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/pl.json b/apps/landing/src/i18n/pl.json index 9da11a85..52f95d80 100644 --- a/apps/landing/src/i18n/pl.json +++ b/apps/landing/src/i18n/pl.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "Sponsorzy", "footer.link.terms": "Regulamin", "footer.link.videoTools": "Narzędzia do wideo", + "footer.status.checking": "Sprawdzanie statusu", + "footer.status.down": "Awaria usługi", + "footer.status.operational": "Wszystkie systemy działają", + "footer.status.partial": "Częściowa awaria", "footer.tagline": "Samodzielnie hostowane przetwarzanie plików. Twoje pliki, Twoja infrastruktura.", "home.categoryCards.audio.blurb": "Konwertuj, przycinaj i transkrybuj", "home.categoryCards.audio.label": "Narzędzia do audio", diff --git a/apps/landing/src/i18n/pl.meta.json b/apps/landing/src/i18n/pl.meta.json index cb30f271..7b59ea33 100644 --- a/apps/landing/src/i18n/pl.meta.json +++ b/apps/landing/src/i18n/pl.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "5ccc15b451b7", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "0e518680e1b8", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "7d5f725ae927", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "43f93b3c9ace", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "437c2ddf09be", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/pt-BR.json b/apps/landing/src/i18n/pt-BR.json index 24512eae..bec64e2a 100644 --- a/apps/landing/src/i18n/pt-BR.json +++ b/apps/landing/src/i18n/pt-BR.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "Patrocinadores", "footer.link.terms": "Termos", "footer.link.videoTools": "Ferramentas de vídeo", + "footer.status.checking": "Verificando o status", + "footer.status.down": "Interrupção do serviço", + "footer.status.operational": "Todos os sistemas operacionais", + "footer.status.partial": "Interrupção parcial", "footer.tagline": "Processamento de arquivos auto-hospedado. Seus arquivos, sua infraestrutura.", "home.categoryCards.audio.blurb": "Converta, corte e transcreva", "home.categoryCards.audio.label": "Ferramentas de áudio", diff --git a/apps/landing/src/i18n/pt-BR.meta.json b/apps/landing/src/i18n/pt-BR.meta.json index 67c7dadc..2f43473a 100644 --- a/apps/landing/src/i18n/pt-BR.meta.json +++ b/apps/landing/src/i18n/pt-BR.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "fb5f986641d1", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "2f7ec907dc67", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "97b5fe59cd19", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "e7798a36be1c", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "78106a539f55", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/ru.json b/apps/landing/src/i18n/ru.json index 2cac13b8..5f68be54 100644 --- a/apps/landing/src/i18n/ru.json +++ b/apps/landing/src/i18n/ru.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "Спонсоры", "footer.link.terms": "Условия", "footer.link.videoTools": "Инструменты для видео", + "footer.status.checking": "Проверка статуса", + "footer.status.down": "Сбой в работе сервиса", + "footer.status.operational": "Все системы работают", + "footer.status.partial": "Частичный сбой", "footer.tagline": "Самостоятельно размещаемая обработка файлов. Ваши файлы, ваша инфраструктура.", "home.categoryCards.audio.blurb": "Конвертируйте, обрезайте и транскрибируйте", "home.categoryCards.audio.label": "Инструменты для аудио", diff --git a/apps/landing/src/i18n/ru.meta.json b/apps/landing/src/i18n/ru.meta.json index 34527e0d..d234a3e6 100644 --- a/apps/landing/src/i18n/ru.meta.json +++ b/apps/landing/src/i18n/ru.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "d897071989ce", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "39016fb23fb8", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "92c73257e0d4", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "450b2f632fd3", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "4a6fb635f59f", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/sv.json b/apps/landing/src/i18n/sv.json index 8ba29380..f2f5f7d8 100644 --- a/apps/landing/src/i18n/sv.json +++ b/apps/landing/src/i18n/sv.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "Sponsorer", "footer.link.terms": "Villkor", "footer.link.videoTools": "Videoverktyg", + "footer.status.checking": "Kontrollerar status", + "footer.status.down": "Driftstörning", + "footer.status.operational": "Alla system fungerar", + "footer.status.partial": "Delvis avbrott", "footer.tagline": "Självhostad filbearbetning. Dina filer, din infrastruktur.", "home.categoryCards.audio.blurb": "Konvertera, klipp och transkribera", "home.categoryCards.audio.label": "Ljudverktyg", diff --git a/apps/landing/src/i18n/sv.meta.json b/apps/landing/src/i18n/sv.meta.json index 89c4defd..6a7ec0ba 100644 --- a/apps/landing/src/i18n/sv.meta.json +++ b/apps/landing/src/i18n/sv.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "b970845d98cb", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "39a833f3c2b4", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "2679f3423e08", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "3652b9d5fc4e", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "9ace411742a2", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/th.json b/apps/landing/src/i18n/th.json index 4bb14c43..2bb675cb 100644 --- a/apps/landing/src/i18n/th.json +++ b/apps/landing/src/i18n/th.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "ผู้สนับสนุน", "footer.link.terms": "ข้อกำหนด", "footer.link.videoTools": "เครื่องมือวิดีโอ", + "footer.status.checking": "กำลังตรวจสอบสถานะ", + "footer.status.down": "บริการขัดข้อง", + "footer.status.operational": "ทุกระบบทำงานปกติ", + "footer.status.partial": "ขัดข้องบางส่วน", "footer.tagline": "ประมวลผลไฟล์แบบโฮสต์เอง ไฟล์ของคุณ โครงสร้างพื้นฐานของคุณ", "home.categoryCards.audio.blurb": "แปลง ตัด และถอดเสียง", "home.categoryCards.audio.label": "เครื่องมือเสียง", diff --git a/apps/landing/src/i18n/th.meta.json b/apps/landing/src/i18n/th.meta.json index 25dd04af..a6816c38 100644 --- a/apps/landing/src/i18n/th.meta.json +++ b/apps/landing/src/i18n/th.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "63b3362278c6", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "69776a79aca8", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "a6eedaa8ff73", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "919c1026fc4b", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "dd6351678012", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/tr.json b/apps/landing/src/i18n/tr.json index 07c0f8e1..0c21ed6b 100644 --- a/apps/landing/src/i18n/tr.json +++ b/apps/landing/src/i18n/tr.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "Sponsorlar", "footer.link.terms": "Şartlar", "footer.link.videoTools": "Video Araçları", + "footer.status.checking": "Durum kontrol ediliyor", + "footer.status.down": "Hizmet kesintisi", + "footer.status.operational": "Tüm sistemler çalışıyor", + "footer.status.partial": "Kısmi kesinti", "footer.tagline": "Kendi sunucunuzda barındırılan dosya işleme. Sizin dosyalarınız, sizin altyapınız.", "home.categoryCards.audio.blurb": "Dönüştürün, kırpın ve metne dökün", "home.categoryCards.audio.label": "Ses Araçları", diff --git a/apps/landing/src/i18n/tr.meta.json b/apps/landing/src/i18n/tr.meta.json index 022e7971..8a5e232c 100644 --- a/apps/landing/src/i18n/tr.meta.json +++ b/apps/landing/src/i18n/tr.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "8f4869afbb23", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "08554b73d2d1", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "aa7e604f9977", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "888a893e465b", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "4b7471af08d9", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/uk.json b/apps/landing/src/i18n/uk.json index be557220..439b485e 100644 --- a/apps/landing/src/i18n/uk.json +++ b/apps/landing/src/i18n/uk.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "Спонсори", "footer.link.terms": "Умови", "footer.link.videoTools": "Інструменти для відео", + "footer.status.checking": "Перевірка статусу", + "footer.status.down": "Збій у роботі сервісу", + "footer.status.operational": "Усі системи працюють", + "footer.status.partial": "Частковий збій", "footer.tagline": "Самостійно розгортувана обробка файлів. Ваші файли, ваша інфраструктура.", "home.categoryCards.audio.blurb": "Конвертуйте, обрізайте та транскрибуйте", "home.categoryCards.audio.label": "Інструменти для аудіо", diff --git a/apps/landing/src/i18n/uk.meta.json b/apps/landing/src/i18n/uk.meta.json index 478ab8c5..a966c5d2 100644 --- a/apps/landing/src/i18n/uk.meta.json +++ b/apps/landing/src/i18n/uk.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "b92ec9f967d6", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "8ff7d1c7337c", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "fc37b5fabd85", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "d157982d7af2", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "1a89631e7f3b", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/vi.json b/apps/landing/src/i18n/vi.json index 9e5c3435..720b015f 100644 --- a/apps/landing/src/i18n/vi.json +++ b/apps/landing/src/i18n/vi.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "Nhà tài trợ", "footer.link.terms": "Điều khoản", "footer.link.videoTools": "Công cụ video", + "footer.status.checking": "Đang kiểm tra trạng thái", + "footer.status.down": "Gián đoạn dịch vụ", + "footer.status.operational": "Tất cả hệ thống bình thường", + "footer.status.partial": "Gián đoạn một phần", "footer.tagline": "Xử lý tệp tự lưu trữ. Tệp của bạn, hạ tầng của bạn.", "home.categoryCards.audio.blurb": "Chuyển đổi, cắt và chuyển thành văn bản", "home.categoryCards.audio.label": "Công cụ âm thanh", diff --git a/apps/landing/src/i18n/vi.meta.json b/apps/landing/src/i18n/vi.meta.json index ad84bb2e..5ddca844 100644 --- a/apps/landing/src/i18n/vi.meta.json +++ b/apps/landing/src/i18n/vi.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "c9fe04b0df2d", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "49115ffaf18a", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "cae0666ae97d", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "6a09095a95ea", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "bfa234c711e2", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/zh-CN.json b/apps/landing/src/i18n/zh-CN.json index 9b42c649..9c11c936 100644 --- a/apps/landing/src/i18n/zh-CN.json +++ b/apps/landing/src/i18n/zh-CN.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "赞助者", "footer.link.terms": "条款", "footer.link.videoTools": "视频工具", + "footer.status.checking": "正在检查状态", + "footer.status.down": "服务中断", + "footer.status.operational": "所有系统运行正常", + "footer.status.partial": "部分服务中断", "footer.tagline": "自托管的文件处理。你的文件,你的基础设施。", "home.categoryCards.audio.blurb": "转换、裁剪与转录", "home.categoryCards.audio.label": "音频工具", diff --git a/apps/landing/src/i18n/zh-CN.meta.json b/apps/landing/src/i18n/zh-CN.meta.json index 46affe91..e04f31ae 100644 --- a/apps/landing/src/i18n/zh-CN.meta.json +++ b/apps/landing/src/i18n/zh-CN.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "d2442cb204fa", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "6473ac29f52a", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "45ac29e8d2dd", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "8a35458ed0e1", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "ace2633874b3", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/apps/landing/src/i18n/zh-TW.json b/apps/landing/src/i18n/zh-TW.json index 33893e6e..fbec1700 100644 --- a/apps/landing/src/i18n/zh-TW.json +++ b/apps/landing/src/i18n/zh-TW.json @@ -227,6 +227,10 @@ "footer.link.sponsors": "贊助者", "footer.link.terms": "條款", "footer.link.videoTools": "影片工具", + "footer.status.checking": "正在檢查狀態", + "footer.status.down": "服務中斷", + "footer.status.operational": "所有系統運作正常", + "footer.status.partial": "部分服務中斷", "footer.tagline": "自架式檔案處理。你的檔案,你的基礎設施。", "home.categoryCards.audio.blurb": "轉檔、剪裁與轉錄", "home.categoryCards.audio.label": "音訊工具", diff --git a/apps/landing/src/i18n/zh-TW.meta.json b/apps/landing/src/i18n/zh-TW.meta.json index 4b64cf61..9459e134 100644 --- a/apps/landing/src/i18n/zh-TW.meta.json +++ b/apps/landing/src/i18n/zh-TW.meta.json @@ -1367,6 +1367,30 @@ "outputHash": "562594ac098f", "stale": false }, + "footer.status.checking": { + "sourceHash": "a4a534f1f446", + "provenance": "machine", + "outputHash": "7cad251fa973", + "stale": false + }, + "footer.status.down": { + "sourceHash": "653599e07324", + "provenance": "machine", + "outputHash": "20f1c4908911", + "stale": false + }, + "footer.status.operational": { + "sourceHash": "39bd8d494d8d", + "provenance": "machine", + "outputHash": "00e82d2be922", + "stale": false + }, + "footer.status.partial": { + "sourceHash": "5b1fb8ceb794", + "provenance": "machine", + "outputHash": "f580bff60ef9", + "stale": false + }, "footer.tagline": { "sourceHash": "3b1e77c7b8b6", "provenance": "machine", diff --git a/tests/e2e-landing/status-indicator.spec.ts b/tests/e2e-landing/status-indicator.spec.ts new file mode 100644 index 00000000..e09a3b94 --- /dev/null +++ b/tests/e2e-landing/status-indicator.spec.ts @@ -0,0 +1,190 @@ +import { expect, test } from "@playwright/test"; +import de from "../../apps/landing/src/i18n/de.json"; + +/** + * `astro dev` does not run _worker.js, so /api/status really is absent here. + * Tests that need a verdict mock it; the ones that do not are exercising the + * degraded path on purpose. + */ + +const INDICATOR = "[data-status-indicator]"; +const DOT = "[data-status-dot]"; +const LABEL = "[data-status-label]"; + +/** --color-muted #6B6560. The label must never take a state color (AA: success is 4.498:1). */ +const MUTED = "rgb(107, 101, 96)"; + +function mockStatus(page: import("@playwright/test").Page, status: string) { + return page.route("**/api/status", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ status }), + }), + ); +} + +/** + * `waitForResponse` resolves when Playwright sees the response over CDP, which + * is ahead of the page's own `.then` chain. Two frames give the badge its + * chance to change, so a "stays grey" assertion proves it did not rather than + * just winning a race. + */ +function flushFrames(page: import("@playwright/test").Page) { + return page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); +} + +test.describe("Footer status indicator", () => { + test("ships the grey state in the served HTML, before any script runs", async ({ page }) => { + // Fetched as raw markup rather than a rendered page, so this covers the + // no-JavaScript reader too. The badge must never be baked green. + const res = await page.request.get("/"); + const html = await res.text(); + expect(html).toContain('data-status="checking"'); + expect(html).toMatch(/]*>Checking status<\/span>/); + // Scoped to the badge's own rendered text rather than the whole document. + // The three verdict strings do appear further down, inside the script's + // label table, because the client has to localize whatever /api/status + // reports. What must never happen is the markup claiming a verdict. + for (const verdict of ["All systems operational", "Partial outage", "Service disruption"]) { + expect(html).not.toMatch(new RegExp(`]*>${verdict}`)); + } + }); + + test("stays grey when the status route is unavailable", async ({ page }) => { + const settled = page.waitForResponse("**/api/status"); + await page.goto("/"); + await settled; + await flushFrames(page); + await expect(page.locator(INDICATOR)).toHaveAttribute("data-status", "checking"); + await expect(page.locator(INDICATOR)).toContainText("Checking status"); + // The state most visitors see first, so it needs pinning too. The dot was + // once #A8A29A: 2.19:1 on the footer, under WCAG 1.4.11's 3:1 for non-text. + // --color-muted is 4.97:1 and is a palette token rather than a magic hex. + await expect(page.locator(DOT)).toHaveCSS("background-color", MUTED); + }); + + // The body is parseable and claims green on purpose. With an empty body the + // badge stays grey because res.json() rejects, so `res.ok` is never exercised + // and deleting it passes. A 5xx carrying a verdict is the realistic case: a + // Cloudflare error envelope, or a stale cached body served on a 502. + test("stays grey when the status route errors, even with a parseable body", async ({ page }) => { + await page.route("**/api/status", (route) => + route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ status: "operational" }), + }), + ); + const settled = page.waitForResponse("**/api/status"); + await page.goto("/"); + await settled; + await flushFrames(page); + await expect(page.locator(INDICATOR)).toHaveAttribute("data-status", "checking"); + await expect(page.locator(INDICATOR)).toContainText("Checking status"); + }); + + // Reaches the .catch through a rejected fetch rather than a bad response. + test("stays grey when the request itself fails", async ({ page }) => { + await page.route("**/api/status", (route) => route.abort("failed")); + await page.goto("/"); + await flushFrames(page); + await expect(page.locator(INDICATOR)).toHaveAttribute("data-status", "checking"); + await expect(page.locator(INDICATOR)).toContainText("Checking status"); + }); + + // Reaches the .catch through res.json() instead. This is the broken-deploy + // signature: _worker.js fails to load, or a _redirects rule shadows the + // route, and the client gets a 200 carrying HTML. + test("stays grey when a 200 carries HTML instead of JSON", async ({ page }) => { + await page.route("**/api/status", (route) => + route.fulfill({ + status: 200, + contentType: "text/html", + body: "404", + }), + ); + const settled = page.waitForResponse("**/api/status"); + await page.goto("/"); + await settled; + await flushFrames(page); + await expect(page.locator(INDICATOR)).toHaveAttribute("data-status", "checking"); + await expect(page.locator(INDICATOR)).toContainText("Checking status"); + }); + + for (const [status, label, dotColor] of [ + ["operational", "All systems operational", "rgb(30, 123, 92)"], + ["partial", "Partial outage", "rgb(168, 85, 24)"], + ["down", "Service disruption", "rgb(190, 58, 53)"], + ] as const) { + test(`renders the ${status} state`, async ({ page }) => { + await mockStatus(page, status); + await page.goto("/"); + // Scoped to the footer, which is where the badge belongs. This subsumes + // the standalone placement test that used to sit at the bottom of the + // file and never observed its own mock. + const badge = page.locator(`footer ${INDICATOR}`); + await expect(badge).toHaveAttribute("data-status", status); + await expect(badge).toContainText(label); + // The dot is the only thing that carries state. Each color clears WCAG + // 1.4.11's 3:1 non-text floor against the footer's --color-background-alt, + // pinned as a ratio in tests/unit/palette-contrast.test.ts. + await expect(badge.locator(DOT)).toHaveCSS("background-color", dotColor); + // And the label never carries it, in any state. --color-success is + // 4.498:1 on the footer's --color-background-alt, just under AA. + // palette-contrast.test.ts pins the dot ratios but cannot answer a + // cascade question like "does the label take a state color", so this + // assertion is the only guard. "down" is the tempting one to redden. + await expect(badge.locator(LABEL)).toHaveCSS("color", MUTED); + }); + } + + // "banana" is absent for the easy reason. "toString" is the one that matters: + // it resolves on Object.prototype, so an unguarded lookup renders + // "function toString() { [native code] }" in the footer. + for (const status of ["banana", "toString"] as const) { + test(`ignores the unrecognized status "${status}"`, async ({ page }) => { + await mockStatus(page, status); + const settled = page.waitForResponse("**/api/status"); + await page.goto("/"); + await settled; + await flushFrames(page); + await expect(page.locator(INDICATOR)).toHaveAttribute("data-status", "checking"); + await expect(page.locator(INDICATOR)).toContainText("Checking status"); + }); + } + + test("names the three services it speaks for", async ({ page }) => { + // The badge reports on snapotter.com's own properties, never on a visitor's + // self-hosted instance. The title is the only thing that says which. + await page.goto("/"); + const title = await page.locator(INDICATOR).getAttribute("title"); + // Compared as whole entries, not substrings: "snapotter.com" is a suffix of + // the other two, so toContain would pass on a title missing the apex. + const hosts = new Set(title?.split(",").map((host) => host.trim())); + expect(hosts).toEqual(new Set(["snapotter.com", "demo.snapotter.com", "docs.snapotter.com"])); + }); + + test("keeps the decorative dot out of the accessible name", async ({ page }) => { + // Without aria-hidden the empty span joins the badge's accessible name. + await page.goto("/"); + await expect(page.locator(DOT)).toHaveAttribute("aria-hidden", "true"); + }); + + test("localizes the badge on a locale-prefixed page", async ({ page }) => { + // Raw markup, no browser: this is about what the server renders for a + // localized tree. Without it, dropping the locale prop from + // in Footer.astro serves English on all 20 and every other test still passes. + const html = await (await page.request.get("/de/")).text(); + const checking = de["footer.status.checking"]; + // Makes the assertion below mean something: it fails loudly if de.json ever + // falls back to the English string rather than silently comparing "" to "". + expect(checking).not.toBe("Checking status"); + expect(html).toContain(`>${checking}`); + // The inline script's label table has to be German too, not just the + // server-rendered default the reader sees first. + expect(html).toContain(JSON.stringify(de["footer.status.operational"])); + }); +}); diff --git a/tests/unit/landing-status-i18n.test.ts b/tests/unit/landing-status-i18n.test.ts new file mode 100644 index 00000000..70971bdd --- /dev/null +++ b/tests/unit/landing-status-i18n.test.ts @@ -0,0 +1,47 @@ +import fs from "node:fs"; +import path from "node:path"; +import { SUPPORTED_LOCALES } from "@snapotter/shared"; +import { describe, expect, it } from "vitest"; + +/** + * The footer status badge resolves all four labels server-side and hands them + * to its inline script, so a catalog gap is invisible on the English page and + * only shows on the localized one. + * + * `t()` falls back with `??`, which does not fall through on `""`. An empty + * translation therefore resolves to an empty string rather than English, and + * the badge would sit blank on that locale forever. Scoped to these four keys + * on purpose: a whole-catalog parity guard is worth having but is a separate + * change, and would trip on pre-existing debt. + */ + +const I18N_DIR = path.resolve(__dirname, "../../apps/landing/src/i18n"); + +const STATUS_KEYS = [ + "footer.status.checking", + "footer.status.operational", + "footer.status.partial", + "footer.status.down", +]; + +const locales = SUPPORTED_LOCALES.map((l) => l.code); + +function catalog(locale: string): Record { + return JSON.parse(fs.readFileSync(path.join(I18N_DIR, `${locale}.json`), "utf-8")); +} + +describe("landing footer status labels", () => { + it("covers every supported locale", () => { + // Guards the loop below: if the locale list were ever empty or unresolvable + // the per-locale assertions would vacuously pass. + expect(locales.length).toBe(21); + }); + + it.each(locales)("%s defines all four status labels as non-empty strings", (locale) => { + const strings = catalog(locale); + for (const key of STATUS_KEYS) { + expect(typeof strings[key], `${locale}.json is missing ${key}`).toBe("string"); + expect(strings[key].trim(), `${locale}.json has an empty ${key}`).not.toBe(""); + } + }); +}); diff --git a/tests/unit/landing-status-worker.test.ts b/tests/unit/landing-status-worker.test.ts new file mode 100644 index 00000000..bc91667e --- /dev/null +++ b/tests/unit/landing-status-worker.test.ts @@ -0,0 +1,185 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import worker from "../../apps/landing/public/_worker.js"; + +/** + * The landing site's Cloudflare Pages worker. `_worker.js` is a dependency-free + * ES module (Pages advanced mode forbids imports), so it can be exercised + * directly with a stubbed global fetch and a fake ASSETS binding. + */ + +const ENV = { ASSETS: { fetch: async () => new Response("asset", { status: 200 }) } }; + +const DEMO = "https://demo.snapotter.com/"; +const DOCS = "https://docs.snapotter.com/"; + +type ProbeCall = { url: string; init: RequestInit | undefined }; + +/** + * Stub global fetch with a per-URL responder, returning the call log. The log + * records the init argument alongside the URL so tests can assert on how a + * probe was issued, not only where it was sent. + */ +function stubProbes(responder: (url: string) => Promise): ProbeCall[] { + const calls: ProbeCall[] = []; + vi.stubGlobal("fetch", async (input: string | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.url; + calls.push({ url, init }); + return responder(url); + }); + return calls; +} + +async function getStatus() { + const res = await worker.fetch(new Request("https://snapotter.com/api/status"), ENV); + return { res, body: (await res.json()) as { status: string } }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + // `unstubAllGlobals` does not undo a `spyOn`, so restore separately. + vi.restoreAllMocks(); +}); + +describe("landing worker /api/status", () => { + it("reports operational when both probed legs answer", async () => { + stubProbes(async () => new Response(null, { status: 200 })); + const { body } = await getStatus(); + expect(body.status).toBe("operational"); + }); + + // Named for what it pins. The stub ignores `redirect: "manual"`, so this + // cannot observe whether redirects are followed, only that a 301 reads as up. + it("counts a 301 as up", async () => { + stubProbes(async () => new Response(null, { status: 301 })); + const { body } = await getStatus(); + expect(body.status).toBe("operational"); + }); + + // Pins the up/down boundary at 400, not 500. A 404 on a sibling property is a + // realistic bad-deploy signature, so it has to read as down rather than up. + it("counts a 404 as down, not just 5xx", async () => { + stubProbes(async (url) => + url === DEMO ? new Response(null, { status: 404 }) : new Response(null, { status: 200 }), + ); + const { body } = await getStatus(); + expect(body.status).toBe("partial"); + }); + + it("reports partial when exactly one leg is down", async () => { + stubProbes(async (url) => + url === DEMO ? new Response(null, { status: 503 }) : new Response(null, { status: 200 }), + ); + const { body } = await getStatus(); + expect(body.status).toBe("partial"); + }); + + it("reports down when both probed legs are down", async () => { + stubProbes(async () => new Response(null, { status: 503 })); + const { res, body } = await getStatus(); + expect(body.status).toBe("down"); + // The badge reads the verdict from the body, so the transport stays 200 + // even when everything probed is down. Answering 503 here would break it. + expect(res.status).toBe(200); + }); + + it("treats a thrown request (timeout, DNS) as down", async () => { + stubProbes(async () => { + throw new Error("timed out"); + }); + const { body } = await getStatus(); + expect(body.status).toBe("down"); + }); + + it("retries a failed leg once before declaring it down", async () => { + let demoAttempts = 0; + const calls = stubProbes(async (url) => { + if (url !== DEMO) return new Response(null, { status: 200 }); + demoAttempts += 1; + if (demoAttempts === 1) throw new Error("transient blip"); + return new Response(null, { status: 200 }); + }); + const { body } = await getStatus(); + expect(body.status).toBe("operational"); + expect(calls.filter((c) => c.url === DEMO)).toHaveLength(2); + }); + + it("gives up after the single retry", async () => { + const calls = stubProbes(async () => new Response(null, { status: 500 })); + await getStatus(); + expect(calls.filter((c) => c.url === DOCS)).toHaveLength(2); + }); + + it("sets the caching and noindex headers on its own response", async () => { + stubProbes(async () => new Response(null, { status: 200 })); + const { res } = await getStatus(); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("application/json"); + expect(res.headers.get("Cache-Control")).toBe("public, max-age=60"); + expect(res.headers.get("X-Robots-Tag")).toBe("noindex"); + }); + + // A false green is cheap to sit on; a false red would pin in the browser + // across every navigation for the full minute. Bad verdicts recheck sooner. + it.each([ + { verdict: "partial", demo: 503, docs: 200 }, + { verdict: "down", demo: 503, docs: 503 }, + ])("caches a $verdict verdict for 15 seconds, not 60", async ({ verdict, demo, docs }) => { + stubProbes(async (url) => + url === DEMO ? new Response(null, { status: demo }) : new Response(null, { status: docs }), + ); + const { res, body } = await getStatus(); + expect(body.status).toBe(verdict); + expect(res.headers.get("Cache-Control")).toBe("public, max-age=15"); + }); + + // Without a bound signal a hung leg would stall the whole route, and the + // footer request behind it, for as long as the edge allows. Asserting merely + // that some signal arrived is not enough: a much longer timeout, or an + // AbortController signal that never fires, would both slip through. The stub + // makes the real deadline unobservable, so pin the budget at the constructor. + it("bounds every probe with a 2 second abort signal", async () => { + const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); + const calls = stubProbes(async () => new Response(null, { status: 200 })); + await getStatus(); + expect(calls).toHaveLength(2); + for (const { init } of calls) { + expect(init).toBeDefined(); + expect(init?.signal).toBeInstanceOf(AbortSignal); + } + expect(timeoutSpy).toHaveBeenCalledWith(2000); + expect(timeoutSpy).toHaveBeenCalledTimes(2); + }); + + // The test above cannot catch a signal hoisted above the retry loop, because + // both legs succeed on attempt 1 and the counts match either way. Force all + // four attempts: a hoisted signal would build 2 signals for 4 fetches and + // hand attempt 2 an already-fired one, deleting the retry. + it("gives each attempt its own deadline, not the first attempt's leftovers", async () => { + const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); + const calls = stubProbes(async () => new Response(null, { status: 500 })); + await getStatus(); + expect(calls).toHaveLength(4); + expect(timeoutSpy).toHaveBeenCalledTimes(4); + expect(new Set(calls.map((c) => c.init?.signal)).size).toBe(4); + }); + + it("probes only the two sibling properties, never snapotter.com itself", async () => { + const calls = stubProbes(async () => new Response(null, { status: 200 })); + await getStatus(); + expect(new Set(calls.map((c) => c.url))).toEqual(new Set([DEMO, DOCS])); + }); +}); + +describe("landing worker existing behavior", () => { + it("still redirects www to the apex", async () => { + const res = await worker.fetch(new Request("https://www.snapotter.com/faq"), ENV); + expect(res.status).toBe(301); + expect(res.headers.get("location")).toBe("https://snapotter.com/faq"); + }); + + it("still serves assets for every other path", async () => { + const res = await worker.fetch(new Request("https://snapotter.com/faq"), ENV); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("asset"); + }); +}); diff --git a/tests/unit/palette-contrast.test.ts b/tests/unit/palette-contrast.test.ts index 111b629d..e1cee2f3 100644 --- a/tests/unit/palette-contrast.test.ts +++ b/tests/unit/palette-contrast.test.ts @@ -159,4 +159,13 @@ describe("landing", () => { expectPair(landing, "muted", "background"); expectPair(landing, "dark-fg", "dark-bg"); }); + + // The footer status dot is a graphic, so WCAG 1.4.11's 3:1 applies rather + // than AA's 4.5. Note "success" clears 3:1 here but not 4.5, which is why the + // badge's label stays muted while only the dot takes a state color. + it("footer status dot clears 3:1 non-text on the footer surface", () => { + for (const dot of ["muted", "success", "primary-ink", "danger"]) { + expectPair(landing, dot, "background-alt", 3); + } + }); });