feat(observability): add handleError hook with errorId and redesigned error page #608
86
docs/adr/018-glitchtip-frontend-error-tracking.md
Normal file
86
docs/adr/018-glitchtip-frontend-error-tracking.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# ADR-018: GlitchTip frontend error tracking via @sentry/sveltekit
|
||||
|
||||
**Date:** 2026-05-17
|
||||
**Status:** Accepted
|
||||
**Deciders:** Marcel Raddatz
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The Familienarchiv had no client-side error reporting. When a user encountered a crash
|
||||
or unhandled error in the SvelteKit frontend, there was no way for the operator to
|
||||
observe it — errors were invisible until a user manually reported them. A GlitchTip
|
||||
instance (self-hosted, Sentry-compatible) was already running as part of the
|
||||
observability stack (`docker-compose.observability.yml`). The backend already reported
|
||||
server-side errors to it.
|
||||
|
||||
We needed a way to:
|
||||
1. Capture frontend errors automatically and route them to GlitchTip.
|
||||
2. Give users a visible error identifier they can include in a support message.
|
||||
3. Do this without leaking personally identifiable information (PII) from the family
|
||||
archive — documents contain personal histories, names, and relationships.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Use `@sentry/sveltekit` (the official Sentry SDK for SvelteKit) to:
|
||||
|
||||
- Initialise with `sendDefaultPii: false` on both `hooks.server.ts` and `hooks.client.ts`.
|
||||
- Pass a callback to `Sentry.handleErrorWithSentry()` that returns
|
||||
`{ message, errorId }` where `errorId` is `Sentry.lastEventId()` when Sentry
|
||||
captured the event, or a fresh `crypto.randomUUID()` as fallback.
|
||||
- Display the `errorId` on the `+error.svelte` page so users can include it in a
|
||||
report to the operator.
|
||||
|
||||
The SDK is initialised with `enabled: !!import.meta.env.VITE_SENTRY_DSN` so that
|
||||
development and CI builds without a DSN configured do not send any events.
|
||||
|
||||
`VITE_SENTRY_DSN` is a write-only ingest key — it can POST events to GlitchTip but
|
||||
cannot read them. It is safe to include in the client bundle per the Sentry security
|
||||
model; it does not require rotation like a password.
|
||||
|
||||
---
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Sentry SaaS** — rejected. The archive contains private family documents and personal
|
||||
history. Sending error events with stack traces to a US-hosted third party is
|
||||
inconsistent with the project's data-minimisation posture. Self-hosted GlitchTip on
|
||||
the same Hetzner VPS keeps all data on infrastructure the operator controls.
|
||||
|
||||
**Custom error logging endpoint** — rejected. The @sentry/sveltekit SDK handles
|
||||
SvelteKit's hook lifecycle, source-map upload, and event grouping automatically.
|
||||
Reimplementing this would cost significant engineering time for no benefit.
|
||||
|
||||
**Log-only (no user-visible errorId)** — rejected. Without a visible error ID, users
|
||||
can only describe what happened in natural language, making it hard to correlate a
|
||||
report with a specific GlitchTip event. The `errorId` closes this gap at negligible UI
|
||||
cost.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Frontend errors are now observable without requiring user reports.
|
||||
- Users can provide an `errorId` that maps directly to a GlitchTip event.
|
||||
- `sendDefaultPii: false` ensures names, IPs, and cookie values are not included in
|
||||
captured events.
|
||||
- `tracesSampleRate: 0.1` limits trace volume to 10% of transactions, keeping
|
||||
GlitchTip load low on the shared VPS.
|
||||
|
||||
**Negative / trade-offs:**
|
||||
- The `@sentry/sveltekit` SDK is now a production dependency. SDK updates must be
|
||||
reviewed for changes to the default PII scrubbing behaviour.
|
||||
- The `handleError` callback in both hooks returns a hardcoded English message
|
||||
(`'An unexpected error occurred'`). This bypasses Paraglide i18n — the error page
|
||||
will always show English text when the hooks are active, regardless of the user's
|
||||
locale. This is acceptable because: (a) the error page is a last-resort fallback
|
||||
not part of normal UX, (b) the `errorId` is the actionable information, not the
|
||||
message text. A future ADR may address this if internationalised error messages
|
||||
become a requirement.
|
||||
- `Sentry.lastEventId()` returns `undefined` when Sentry did not capture the event
|
||||
(e.g. DSN not configured). The `crypto.randomUUID()` fallback guarantees an `errorId`
|
||||
is always present, but that UUID will not appear in GlitchTip.
|
||||
@@ -8,9 +8,11 @@ Person(member, "Family Member", "Access by administrator invite. Searches, brows
|
||||
|
||||
System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents")
|
||||
System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.")
|
||||
System_Ext(glitchtip, "GlitchTip", "Self-hosted error tracking (Sentry-compatible). Receives frontend and backend error events with stack traces.")
|
||||
|
||||
Rel(admin, familienarchiv, "Manages via browser", "HTTPS")
|
||||
Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS")
|
||||
Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP")
|
||||
Rel(familienarchiv, glitchtip, "Sends error events with errorId and stack trace", "HTTPS")
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -38,7 +38,10 @@ export default defineConfig(
|
||||
'no-undef': 'off',
|
||||
// This rule is designed for Svelte 5's own routing system using resolve().
|
||||
// In SvelteKit, <a href> and goto() from $app/navigation are the correct patterns — resolve() is not needed.
|
||||
'svelte/no-navigation-without-resolve': 'off'
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
// Prevents accidental console.log left in source. console.warn and console.error
|
||||
// are still permitted for intentional server-side logging (e.g. hooks.server.ts).
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -71,6 +74,13 @@ export default defineConfig(
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
// E2E tests use console.log for diagnostic output — allow it there.
|
||||
files: ['e2e/**'],
|
||||
rules: {
|
||||
'no-console': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts', '**/*.test.ts'],
|
||||
rules: {
|
||||
|
||||
@@ -473,7 +473,7 @@
|
||||
"dashboard_reader_stats_persons_short": "Pers.",
|
||||
"dashboard_reader_stats_stories_short": "Gesch.",
|
||||
"dashboard_reader_draft_meta": "Entwurf · zuletzt bearbeitet {relative}",
|
||||
"dashboard_resume_label": "Zuletzt geöffnet:",
|
||||
"dashboard_resume_label": "Weiter, wo du aufgehört hast",
|
||||
"dashboard_resume_fallback": "Unbekanntes Dokument",
|
||||
"doc_status_placeholder": "Platzhalter",
|
||||
"doc_status_uploaded": "Hochgeladen",
|
||||
@@ -773,19 +773,15 @@
|
||||
"admin_invite_created_title": "Einladung erstellt",
|
||||
"admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:",
|
||||
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?",
|
||||
|
||||
"greeting_morning": "Guten Morgen, {name}.",
|
||||
"greeting_day": "Hallo, {name}.",
|
||||
"greeting_evening": "Guten Abend, {name}.",
|
||||
|
||||
"dashboard_resume_label": "Weiter, wo du aufgehört hast",
|
||||
"dashboard_blocks": "{count} Abschnitte",
|
||||
"dashboard_resume_cta": "Weitertranskribieren",
|
||||
"dashboard_resume_other": "oder anderen Brief wählen",
|
||||
"dashboard_empty_title": "Noch kein Dokument begonnen",
|
||||
"dashboard_empty_body": "Wähle ein Dokument aus dem Archiv, um mit der Transkription zu beginnen.",
|
||||
"dashboard_empty_cta": "Zum Archiv",
|
||||
|
||||
"dashboard_mission_caption": "Offene Aufgaben",
|
||||
"queue_segment": "Segmentieren",
|
||||
"queue_segment_blurb": "Seiten aufteilen",
|
||||
@@ -795,7 +791,6 @@
|
||||
"queue_review_blurb": "Texte kontrollieren",
|
||||
"queue_n_open": "{n} offen",
|
||||
"queue_show_all": "Alle anzeigen →",
|
||||
|
||||
"pulse_eyebrow": "Diese Woche",
|
||||
"pulse_headline": "Ihr habt {pages} Seiten bearbeitet.",
|
||||
"pulse_you": "Du selbst hast {pages} davon bearbeitet.",
|
||||
@@ -803,19 +798,15 @@
|
||||
"pulse_transcribed": "Textstellen markiert",
|
||||
"pulse_reviewed": "Textstellen transkribiert",
|
||||
"pulse_uploaded": "Dokumente hochgeladen",
|
||||
|
||||
"feed_caption": "Kommentare & Aktivität",
|
||||
"feed_show_all": "Alle anzeigen",
|
||||
"feed_for_you": "für dich",
|
||||
|
||||
"audit_action_text_saved": "hat Text gespeichert in",
|
||||
"audit_action_file_uploaded": "hat eine Datei hochgeladen:",
|
||||
"audit_action_annotation_created": "hat eine Markierung erstellt in",
|
||||
"audit_action_comment_added": "hat kommentiert:",
|
||||
"audit_action_mention_created": "hat dich erwähnt in",
|
||||
|
||||
"dropzone_release": "Loslassen zum Hochladen",
|
||||
|
||||
"chronik_page_title": "Aktivitäten",
|
||||
"chronik_for_you_caption": "Für dich",
|
||||
"chronik_for_you_count": "{count} neu",
|
||||
@@ -859,9 +850,7 @@
|
||||
"pagination_page_of": "Seite {page} von {total}",
|
||||
"pagination_nav_label": "Seitennavigation",
|
||||
"pagination_page_button": "Seite {page}",
|
||||
|
||||
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
||||
|
||||
"transcribe_coach_title": "Erste Transkription?",
|
||||
"transcribe_coach_preamble": "Unser Kurrent-Erkenner lernt noch. Jede Transkription, die Sie zum Training freigeben, bringt ihm die Schrift bei — so funktioniert's:",
|
||||
"transcribe_coach_step_1_title": "Rahmen ziehen.",
|
||||
@@ -871,10 +860,8 @@
|
||||
"transcribe_coach_step_3_title": "Speichert automatisch.",
|
||||
"transcribe_coach_footer_kurrent": "Hilfe zu Kurrent ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Transkriptions-Richtlinien ↗",
|
||||
|
||||
"transcription_mode_help_label": "Lese- und Bearbeitungsmodus",
|
||||
"transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.",
|
||||
|
||||
"richtlinien_title": "Transkriptions-Richtlinien",
|
||||
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal wer tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
|
||||
"richtlinien_wiki_text": "Kurrent- und Sütterlin-Alphabete sind bei Wikipedia gut erklärt. Hier stehen nur unsere eigenen Vereinbarungen für dieses Archiv.",
|
||||
@@ -948,12 +935,9 @@
|
||||
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
|
||||
"bulk_edit_topbar_title": "Massenbearbeitung",
|
||||
"bulk_edit_count_pill": "{count} werden bearbeitet",
|
||||
|
||||
"nav_stammbaum": "Stammbaum",
|
||||
"nav_geschichten": "Geschichten",
|
||||
|
||||
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
|
||||
|
||||
"geschichten_index_title": "Geschichten",
|
||||
"geschichten_new_button": "Neue Geschichte",
|
||||
"geschichten_filter_all_pill": "Alle",
|
||||
@@ -973,7 +957,6 @@
|
||||
"geschichten_card_attach_action": "+ Geschichte anhängen",
|
||||
"geschichten_card_show_all_for_person": "Alle Geschichten zu {name}",
|
||||
"geschichten_card_show_all": "Alle anzeigen",
|
||||
|
||||
"geschichte_editor_title_placeholder": "Titel der Geschichte",
|
||||
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
|
||||
"geschichte_editor_status_draft": "ENTWURF",
|
||||
@@ -1000,14 +983,11 @@
|
||||
"geschichte_editor_toolbar_h3": "Unterüberschrift",
|
||||
"geschichte_editor_toolbar_ul": "Aufzählung",
|
||||
"geschichte_editor_toolbar_ol": "Nummerierte Liste",
|
||||
|
||||
"geschichte_delete_confirm_title": "Geschichte löschen?",
|
||||
"geschichte_delete_confirm_body": "Diese Aktion kann nicht rückgängig gemacht werden. Die Geschichte wird dauerhaft gelöscht und aus allen verlinkten Personen- und Dokumentseiten entfernt.",
|
||||
|
||||
"error_relationship_not_found": "Die Beziehung wurde nicht gefunden.",
|
||||
"error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.",
|
||||
"error_duplicate_relationship": "Diese Beziehung gibt es bereits.",
|
||||
|
||||
"relation_parent_of": "Elternteil von",
|
||||
"relation_child_of": "Kind von",
|
||||
"relation_spouse_of": "Ehegatte",
|
||||
@@ -1018,7 +998,6 @@
|
||||
"relation_doctor": "Arzt",
|
||||
"relation_neighbor": "Nachbar",
|
||||
"relation_other": "Sonstige",
|
||||
|
||||
"relation_inferred_parent": "Elternteil",
|
||||
"relation_inferred_child": "Kind",
|
||||
"relation_inferred_spouse": "Ehegatte",
|
||||
@@ -1036,9 +1015,7 @@
|
||||
"relation_inferred_sibling_inlaw": "Schwager/Schwägerin",
|
||||
"relation_inferred_cousin_1": "Cousin/Cousine",
|
||||
"relation_inferred_distant": "Weitläufige Verwandtschaft",
|
||||
|
||||
"doc_details_field_relationship": "Verwandtschaft",
|
||||
|
||||
"stammbaum_empty_heading": "Noch keine Familienmitglieder",
|
||||
"stammbaum_empty_body": "Markiere Personen auf ihrer Bearbeitungsseite als Familienmitglied, damit sie hier erscheinen.",
|
||||
"stammbaum_empty_link": "→ Zur Personenliste",
|
||||
@@ -1050,7 +1027,6 @@
|
||||
"stammbaum_zoom_in": "Vergrößern",
|
||||
"stammbaum_zoom_out": "Verkleinern",
|
||||
"stammbaum_generations": "Generationen",
|
||||
|
||||
"relation_error_duplicate": "Diese Beziehung gibt es bereits.",
|
||||
"relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.",
|
||||
"relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.",
|
||||
@@ -1073,14 +1049,15 @@
|
||||
"relation_form_field_from_year": "Von Jahr",
|
||||
"relation_form_field_to_year": "Bis Jahr",
|
||||
"relation_form_year_placeholder": "z.B. 1920",
|
||||
|
||||
"person_relationships_heading": "Beziehungen",
|
||||
"person_relationships_empty": "Noch keine Beziehungen bekannt.",
|
||||
|
||||
"timeline_aria_label": "Zeitachse Dokumentdichte",
|
||||
"timeline_clear_selection": "Auswahl zurücksetzen",
|
||||
"timeline_zoom_reset": "Zurück zur Übersicht",
|
||||
"timeline_bar_aria_singular": "{when}, 1 Dokument",
|
||||
"timeline_bar_aria_plural": "{when}, {count} Dokumente",
|
||||
"timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt"
|
||||
"timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt",
|
||||
"error_page_id_label": "Fehler-ID",
|
||||
"error_copy_id_label": "ID kopieren",
|
||||
"error_copied": "Kopiert!"
|
||||
}
|
||||
|
||||
@@ -473,7 +473,7 @@
|
||||
"dashboard_reader_stats_persons_short": "Pers.",
|
||||
"dashboard_reader_stats_stories_short": "Stor.",
|
||||
"dashboard_reader_draft_meta": "Draft · last edited {relative}",
|
||||
"dashboard_resume_label": "Last opened:",
|
||||
"dashboard_resume_label": "Continue where you left off",
|
||||
"dashboard_resume_fallback": "Unknown document",
|
||||
"doc_status_placeholder": "Placeholder",
|
||||
"doc_status_uploaded": "Uploaded",
|
||||
@@ -773,19 +773,15 @@
|
||||
"admin_invite_created_title": "Invite created",
|
||||
"admin_invite_created_desc": "Share this link with the person you are inviting:",
|
||||
"admin_invite_revoke_confirm": "Really revoke this invite?",
|
||||
|
||||
"greeting_morning": "Good morning, {name}.",
|
||||
"greeting_day": "Hello, {name}.",
|
||||
"greeting_evening": "Good evening, {name}.",
|
||||
|
||||
"dashboard_resume_label": "Continue where you left off",
|
||||
"dashboard_blocks": "{count} sections",
|
||||
"dashboard_resume_cta": "Continue transcribing",
|
||||
"dashboard_resume_other": "or choose another document",
|
||||
"dashboard_empty_title": "No document started yet",
|
||||
"dashboard_empty_body": "Choose a document from the archive to start transcribing.",
|
||||
"dashboard_empty_cta": "To the archive",
|
||||
|
||||
"dashboard_mission_caption": "Open tasks",
|
||||
"queue_segment": "Segment",
|
||||
"queue_segment_blurb": "Split pages",
|
||||
@@ -795,7 +791,6 @@
|
||||
"queue_review_blurb": "Check texts",
|
||||
"queue_n_open": "{n} open",
|
||||
"queue_show_all": "Show all →",
|
||||
|
||||
"pulse_eyebrow": "This week",
|
||||
"pulse_headline": "You have worked on {pages} pages.",
|
||||
"pulse_you": "You personally worked on {pages} of them.",
|
||||
@@ -803,19 +798,15 @@
|
||||
"pulse_transcribed": "Passages annotated",
|
||||
"pulse_reviewed": "Passages transcribed",
|
||||
"pulse_uploaded": "Documents uploaded",
|
||||
|
||||
"feed_caption": "Comments & activity",
|
||||
"feed_show_all": "Show all",
|
||||
"feed_for_you": "for you",
|
||||
|
||||
"audit_action_text_saved": "saved text in",
|
||||
"audit_action_file_uploaded": "uploaded a file:",
|
||||
"audit_action_annotation_created": "created an annotation in",
|
||||
"audit_action_comment_added": "commented:",
|
||||
"audit_action_mention_created": "mentioned you in",
|
||||
|
||||
"dropzone_release": "Release to upload",
|
||||
|
||||
"chronik_page_title": "Activity",
|
||||
"chronik_for_you_caption": "For you",
|
||||
"chronik_for_you_count": "{count} new",
|
||||
@@ -859,9 +850,7 @@
|
||||
"pagination_page_of": "Page {page} of {total}",
|
||||
"pagination_nav_label": "Pagination",
|
||||
"pagination_page_button": "Page {page}",
|
||||
|
||||
"common_opens_new_tab": "(opens in new tab)",
|
||||
|
||||
"transcribe_coach_title": "First transcription?",
|
||||
"transcribe_coach_preamble": "Our Kurrent recogniser is still learning. Every transcription you release for training teaches it the handwriting — here's how it works:",
|
||||
"transcribe_coach_step_1_title": "Draw a frame.",
|
||||
@@ -871,10 +860,8 @@
|
||||
"transcribe_coach_step_3_title": "Saves automatically.",
|
||||
"transcribe_coach_footer_kurrent": "Kurrent help ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Transcription guidelines ↗",
|
||||
|
||||
"transcription_mode_help_label": "Read and edit mode",
|
||||
"transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
|
||||
|
||||
"richtlinien_title": "Transcription Guidelines",
|
||||
"richtlinien_intro": "So every letter is transcribed consistently — no matter who types — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
|
||||
"richtlinien_wiki_text": "The Kurrent and Sütterlin alphabets are well explained on Wikipedia. Here you'll only find our own conventions for this archive.",
|
||||
@@ -948,12 +935,9 @@
|
||||
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
|
||||
"bulk_edit_topbar_title": "Bulk edit",
|
||||
"bulk_edit_count_pill": "{count} will be edited",
|
||||
|
||||
"nav_stammbaum": "Family tree",
|
||||
"nav_geschichten": "Stories",
|
||||
|
||||
"error_geschichte_not_found": "The story was not found.",
|
||||
|
||||
"geschichten_index_title": "Stories",
|
||||
"geschichten_new_button": "New story",
|
||||
"geschichten_filter_all_pill": "All",
|
||||
@@ -973,7 +957,6 @@
|
||||
"geschichten_card_attach_action": "+ Attach a story",
|
||||
"geschichten_card_show_all_for_person": "All stories about {name}",
|
||||
"geschichten_card_show_all": "Show all",
|
||||
|
||||
"geschichte_editor_title_placeholder": "Story title",
|
||||
"geschichte_editor_body_placeholder": "Write your story here…",
|
||||
"geschichte_editor_status_draft": "DRAFT",
|
||||
@@ -1000,14 +983,11 @@
|
||||
"geschichte_editor_toolbar_h3": "Subheading",
|
||||
"geschichte_editor_toolbar_ul": "Bulleted list",
|
||||
"geschichte_editor_toolbar_ol": "Numbered list",
|
||||
|
||||
"geschichte_delete_confirm_title": "Delete story?",
|
||||
"geschichte_delete_confirm_body": "This action cannot be undone. The story will be permanently deleted and removed from all linked person and document pages.",
|
||||
|
||||
"error_relationship_not_found": "Relationship not found.",
|
||||
"error_circular_relationship": "This relationship would form a cycle.",
|
||||
"error_duplicate_relationship": "This relationship already exists.",
|
||||
|
||||
"relation_parent_of": "Parent of",
|
||||
"relation_child_of": "Child of",
|
||||
"relation_spouse_of": "Spouse",
|
||||
@@ -1018,7 +998,6 @@
|
||||
"relation_doctor": "Doctor",
|
||||
"relation_neighbor": "Neighbour",
|
||||
"relation_other": "Other",
|
||||
|
||||
"relation_inferred_parent": "Parent",
|
||||
"relation_inferred_child": "Child",
|
||||
"relation_inferred_spouse": "Spouse",
|
||||
@@ -1036,9 +1015,7 @@
|
||||
"relation_inferred_sibling_inlaw": "Sibling-in-law",
|
||||
"relation_inferred_cousin_1": "Cousin",
|
||||
"relation_inferred_distant": "Distant relative",
|
||||
|
||||
"doc_details_field_relationship": "Relationship",
|
||||
|
||||
"stammbaum_empty_heading": "No family members yet",
|
||||
"stammbaum_empty_body": "Mark a person as a family member on their edit page so they appear here.",
|
||||
"stammbaum_empty_link": "→ Go to person list",
|
||||
@@ -1050,7 +1027,6 @@
|
||||
"stammbaum_zoom_in": "Zoom in",
|
||||
"stammbaum_zoom_out": "Zoom out",
|
||||
"stammbaum_generations": "Generations",
|
||||
|
||||
"relation_error_duplicate": "This relationship already exists.",
|
||||
"relation_error_circular": "This relationship would form a cycle.",
|
||||
"relation_error_self": "A person cannot be related to themselves.",
|
||||
@@ -1073,14 +1049,15 @@
|
||||
"relation_form_field_from_year": "From year",
|
||||
"relation_form_field_to_year": "To year",
|
||||
"relation_form_year_placeholder": "e.g. 1920",
|
||||
|
||||
"person_relationships_heading": "Relationships",
|
||||
"person_relationships_empty": "No relationships known yet.",
|
||||
|
||||
"timeline_aria_label": "Document density timeline",
|
||||
"timeline_clear_selection": "Clear selection",
|
||||
"timeline_zoom_reset": "Reset zoom",
|
||||
"timeline_bar_aria_singular": "{when}, 1 document",
|
||||
"timeline_bar_aria_plural": "{when}, {count} documents",
|
||||
"timeline_dragging_aria_live": "Range {from} to {to} selected"
|
||||
"timeline_dragging_aria_live": "Range {from} to {to} selected",
|
||||
"error_page_id_label": "Error ID",
|
||||
"error_copy_id_label": "Copy ID",
|
||||
"error_copied": "Copied!"
|
||||
}
|
||||
|
||||
@@ -473,7 +473,7 @@
|
||||
"dashboard_reader_stats_persons_short": "Pers.",
|
||||
"dashboard_reader_stats_stories_short": "Hist.",
|
||||
"dashboard_reader_draft_meta": "Borrador · editado hace {relative}",
|
||||
"dashboard_resume_label": "Último abierto:",
|
||||
"dashboard_resume_label": "Continuar donde lo dejaste",
|
||||
"dashboard_resume_fallback": "Documento desconocido",
|
||||
"doc_status_placeholder": "Marcador",
|
||||
"doc_status_uploaded": "Cargado",
|
||||
@@ -773,19 +773,15 @@
|
||||
"admin_invite_created_title": "Invitación creada",
|
||||
"admin_invite_created_desc": "Comparte este enlace con la persona invitada:",
|
||||
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?",
|
||||
|
||||
"greeting_morning": "Buenos días, {name}.",
|
||||
"greeting_day": "Hola, {name}.",
|
||||
"greeting_evening": "Buenas noches, {name}.",
|
||||
|
||||
"dashboard_resume_label": "Continuar donde lo dejaste",
|
||||
"dashboard_blocks": "{count} secciones",
|
||||
"dashboard_resume_cta": "Continuar transcripción",
|
||||
"dashboard_resume_other": "o elige otro documento",
|
||||
"dashboard_empty_title": "Aún no has comenzado ningún documento",
|
||||
"dashboard_empty_body": "Elige un documento del archivo para empezar a transcribir.",
|
||||
"dashboard_empty_cta": "Al archivo",
|
||||
|
||||
"dashboard_mission_caption": "Tareas pendientes",
|
||||
"queue_segment": "Segmentar",
|
||||
"queue_segment_blurb": "Dividir páginas",
|
||||
@@ -795,7 +791,6 @@
|
||||
"queue_review_blurb": "Controlar textos",
|
||||
"queue_n_open": "{n} pendiente",
|
||||
"queue_show_all": "Ver todo →",
|
||||
|
||||
"pulse_eyebrow": "Esta semana",
|
||||
"pulse_headline": "Habéis trabajado {pages} páginas.",
|
||||
"pulse_you": "Tú mismo has trabajado {pages} de ellas.",
|
||||
@@ -803,19 +798,15 @@
|
||||
"pulse_transcribed": "Fragmentos anotados",
|
||||
"pulse_reviewed": "Fragmentos transcritos",
|
||||
"pulse_uploaded": "Documentos subidos",
|
||||
|
||||
"feed_caption": "Comentarios y actividad",
|
||||
"feed_show_all": "Ver todo",
|
||||
"feed_for_you": "para ti",
|
||||
|
||||
"audit_action_text_saved": "guardó texto en",
|
||||
"audit_action_file_uploaded": "subió un archivo:",
|
||||
"audit_action_annotation_created": "creó una anotación en",
|
||||
"audit_action_comment_added": "comentó:",
|
||||
"audit_action_mention_created": "te mencionó en",
|
||||
|
||||
"dropzone_release": "Suelta para subir",
|
||||
|
||||
"chronik_page_title": "Actividades",
|
||||
"chronik_for_you_caption": "Para ti",
|
||||
"chronik_for_you_count": "{count} nuevas",
|
||||
@@ -859,9 +850,7 @@
|
||||
"pagination_page_of": "Página {page} de {total}",
|
||||
"pagination_nav_label": "Paginación",
|
||||
"pagination_page_button": "Página {page}",
|
||||
|
||||
"common_opens_new_tab": "(abre en pestaña nueva)",
|
||||
|
||||
"transcribe_coach_title": "¿Primera transcripción?",
|
||||
"transcribe_coach_preamble": "Nuestro reconocedor de Kurrent aún está aprendiendo. Cada transcripción que libera para el entrenamiento le enseña la escritura — así funciona:",
|
||||
"transcribe_coach_step_1_title": "Dibujar un marco.",
|
||||
@@ -871,10 +860,8 @@
|
||||
"transcribe_coach_step_3_title": "Se guarda automáticamente.",
|
||||
"transcribe_coach_footer_kurrent": "Ayuda sobre Kurrent ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Normas de transcripción ↗",
|
||||
|
||||
"transcription_mode_help_label": "Modo lectura y edición",
|
||||
"transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.",
|
||||
|
||||
"richtlinien_title": "Normas de transcripción",
|
||||
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — sin importar quién transcriba — aquí están nuestras reglas. La página crece con nosotros.",
|
||||
"richtlinien_wiki_text": "Los alfabetos Kurrent y Sütterlin están bien explicados en Wikipedia. Aquí solo se recogen nuestros propios acuerdos para este archivo.",
|
||||
@@ -948,12 +935,9 @@
|
||||
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
|
||||
"bulk_edit_topbar_title": "Edición masiva",
|
||||
"bulk_edit_count_pill": "Se editarán {count}",
|
||||
|
||||
"nav_stammbaum": "Árbol genealógico",
|
||||
"nav_geschichten": "Historias",
|
||||
|
||||
"error_geschichte_not_found": "No se encontró la historia.",
|
||||
|
||||
"geschichten_index_title": "Historias",
|
||||
"geschichten_new_button": "Nueva historia",
|
||||
"geschichten_filter_all_pill": "Todas",
|
||||
@@ -973,7 +957,6 @@
|
||||
"geschichten_card_attach_action": "+ Adjuntar historia",
|
||||
"geschichten_card_show_all_for_person": "Todas las historias sobre {name}",
|
||||
"geschichten_card_show_all": "Mostrar todas",
|
||||
|
||||
"geschichte_editor_title_placeholder": "Título de la historia",
|
||||
"geschichte_editor_body_placeholder": "Escribe tu historia aquí…",
|
||||
"geschichte_editor_status_draft": "BORRADOR",
|
||||
@@ -1000,14 +983,11 @@
|
||||
"geschichte_editor_toolbar_h3": "Subencabezado",
|
||||
"geschichte_editor_toolbar_ul": "Lista con viñetas",
|
||||
"geschichte_editor_toolbar_ol": "Lista numerada",
|
||||
|
||||
"geschichte_delete_confirm_title": "¿Eliminar historia?",
|
||||
"geschichte_delete_confirm_body": "Esta acción no se puede deshacer. La historia se eliminará permanentemente y se quitará de todas las páginas de personas y documentos vinculados.",
|
||||
|
||||
"error_relationship_not_found": "La relación no fue encontrada.",
|
||||
"error_circular_relationship": "Esta relación crearía un ciclo.",
|
||||
"error_duplicate_relationship": "Esta relación ya existe.",
|
||||
|
||||
"relation_parent_of": "Progenitor de",
|
||||
"relation_child_of": "Hijo/a de",
|
||||
"relation_spouse_of": "Cónyuge",
|
||||
@@ -1018,7 +998,6 @@
|
||||
"relation_doctor": "Médico",
|
||||
"relation_neighbor": "Vecino/a",
|
||||
"relation_other": "Otro",
|
||||
|
||||
"relation_inferred_parent": "Progenitor",
|
||||
"relation_inferred_child": "Hijo/a",
|
||||
"relation_inferred_spouse": "Cónyuge",
|
||||
@@ -1036,9 +1015,7 @@
|
||||
"relation_inferred_sibling_inlaw": "Cuñado/a",
|
||||
"relation_inferred_cousin_1": "Primo/a",
|
||||
"relation_inferred_distant": "Pariente lejano",
|
||||
|
||||
"doc_details_field_relationship": "Parentesco",
|
||||
|
||||
"stammbaum_empty_heading": "Aún no hay miembros de la familia",
|
||||
"stammbaum_empty_body": "Marca a una persona como miembro de la familia en su página de edición para que aparezca aquí.",
|
||||
"stammbaum_empty_link": "→ Ir a la lista de personas",
|
||||
@@ -1050,7 +1027,6 @@
|
||||
"stammbaum_zoom_in": "Acercar",
|
||||
"stammbaum_zoom_out": "Alejar",
|
||||
"stammbaum_generations": "Generaciones",
|
||||
|
||||
"relation_error_duplicate": "Esta relación ya existe.",
|
||||
"relation_error_circular": "Esta relación crearía un ciclo.",
|
||||
"relation_error_self": "Una persona no puede estar relacionada consigo misma.",
|
||||
@@ -1073,14 +1049,15 @@
|
||||
"relation_form_field_from_year": "Desde año",
|
||||
"relation_form_field_to_year": "Hasta año",
|
||||
"relation_form_year_placeholder": "ej. 1920",
|
||||
|
||||
"person_relationships_heading": "Relaciones",
|
||||
"person_relationships_empty": "Aún no se conocen relaciones.",
|
||||
|
||||
"timeline_aria_label": "Cronología de densidad de documentos",
|
||||
"timeline_clear_selection": "Borrar selección",
|
||||
"timeline_zoom_reset": "Restablecer zoom",
|
||||
"timeline_bar_aria_singular": "{when}, 1 documento",
|
||||
"timeline_bar_aria_plural": "{when}, {count} documentos",
|
||||
"timeline_dragging_aria_live": "Rango {from} a {to} seleccionado"
|
||||
"timeline_dragging_aria_live": "Rango {from} a {to} seleccionado",
|
||||
"error_page_id_label": "ID de error",
|
||||
"error_copy_id_label": "Copiar ID",
|
||||
"error_copied": "¡Copiado!"
|
||||
}
|
||||
|
||||
5
frontend/src/app.d.ts
vendored
5
frontend/src/app.d.ts
vendored
@@ -26,6 +26,11 @@ declare global {
|
||||
interface PageData {
|
||||
user?: User; // Available in $page.data.user
|
||||
}
|
||||
|
||||
interface Error {
|
||||
message: string;
|
||||
errorId?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
frontend/src/hooks.client.test.ts
Normal file
47
frontend/src/hooks.client.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@sentry/sveltekit', () => ({
|
||||
init: vi.fn(),
|
||||
handleErrorWithSentry: (fn: (args: unknown) => unknown) => fn,
|
||||
lastEventId: vi.fn(() => 'sentry-event-id-abc123')
|
||||
}));
|
||||
|
||||
describe('hooks.client handleError', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('returns Sentry lastEventId as errorId', async () => {
|
||||
const Sentry = await import('@sentry/sveltekit');
|
||||
vi.mocked(Sentry.lastEventId).mockReturnValue('sentry-event-id-abc123');
|
||||
|
||||
const { handleError } = await import('./hooks.client');
|
||||
const result = (handleError as (args: unknown) => { message: string; errorId: string })({
|
||||
error: new Error('boom'),
|
||||
event: {},
|
||||
status: 500,
|
||||
message: 'Internal Error'
|
||||
});
|
||||
|
||||
expect(result.errorId).toBe('sentry-event-id-abc123');
|
||||
expect(result.message).toBe('An unexpected error occurred');
|
||||
});
|
||||
|
||||
it('falls back to crypto.randomUUID when lastEventId returns undefined', async () => {
|
||||
const Sentry = await import('@sentry/sveltekit');
|
||||
vi.mocked(Sentry.lastEventId).mockReturnValue(undefined);
|
||||
|
||||
const { handleError } = await import('./hooks.client');
|
||||
const result = (handleError as (args: unknown) => { message: string; errorId: string })({
|
||||
error: new Error('boom'),
|
||||
event: {},
|
||||
status: 500,
|
||||
message: 'Internal Error'
|
||||
});
|
||||
|
||||
expect(result.errorId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
);
|
||||
expect(result.message).toBe('An unexpected error occurred');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import * as Sentry from '@sentry/sveltekit';
|
||||
|
||||
// VITE_SENTRY_DSN is a write-only ingest key — it can POST events to GlitchTip
|
||||
// but cannot read them. Safe to include in the client bundle per Sentry security model.
|
||||
Sentry.init({
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
environment: import.meta.env.MODE,
|
||||
tracesSampleRate: 1.0,
|
||||
tracesSampleRate: 0.1,
|
||||
sendDefaultPii: false,
|
||||
enabled: !!import.meta.env.VITE_SENTRY_DSN
|
||||
});
|
||||
|
||||
export const handleError = Sentry.handleErrorWithSentry();
|
||||
export const handleError = Sentry.handleErrorWithSentry(() => {
|
||||
const errorId = Sentry.lastEventId() ?? crypto.randomUUID();
|
||||
return { message: 'An unexpected error occurred', errorId };
|
||||
});
|
||||
|
||||
58
frontend/src/hooks.server.test.ts
Normal file
58
frontend/src/hooks.server.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@sentry/sveltekit', () => ({
|
||||
init: vi.fn(),
|
||||
handleErrorWithSentry: (fn: (args: unknown) => unknown) => fn,
|
||||
lastEventId: vi.fn(() => 'sentry-event-id-abc123')
|
||||
}));
|
||||
|
||||
vi.mock('@sveltejs/kit', () => ({ redirect: vi.fn() }));
|
||||
vi.mock('@sveltejs/kit/hooks', () => ({ sequence: vi.fn((...fns: unknown[]) => fns[0]) }));
|
||||
vi.mock('$lib/paraglide/server', () => ({ paraglideMiddleware: vi.fn() }));
|
||||
vi.mock('$lib/paraglide/runtime', () => ({ cookieName: 'locale', cookieMaxAge: 86400 }));
|
||||
vi.mock('$lib/shared/server/locale', () => ({ detectLocale: vi.fn(() => 'de') }));
|
||||
|
||||
const makeEvent = () => ({
|
||||
url: { pathname: '/documents/123' },
|
||||
locals: {}
|
||||
});
|
||||
|
||||
describe('hooks.server handleError', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('returns Sentry lastEventId as errorId', async () => {
|
||||
const Sentry = await import('@sentry/sveltekit');
|
||||
vi.mocked(Sentry.lastEventId).mockReturnValue('sentry-event-id-abc123');
|
||||
|
||||
const { handleError } = await import('./hooks.server');
|
||||
const result = (handleError as (args: unknown) => { message: string; errorId: string })({
|
||||
error: new Error('boom'),
|
||||
event: makeEvent(),
|
||||
status: 500,
|
||||
message: 'Internal Error'
|
||||
});
|
||||
|
||||
expect(result.errorId).toBe('sentry-event-id-abc123');
|
||||
expect(result.message).toBe('An unexpected error occurred');
|
||||
});
|
||||
|
||||
it('falls back to crypto.randomUUID when lastEventId returns undefined', async () => {
|
||||
const Sentry = await import('@sentry/sveltekit');
|
||||
vi.mocked(Sentry.lastEventId).mockReturnValue(undefined);
|
||||
|
||||
const { handleError } = await import('./hooks.server');
|
||||
const result = (handleError as (args: unknown) => { message: string; errorId: string })({
|
||||
error: new Error('boom'),
|
||||
event: makeEvent(),
|
||||
status: 500,
|
||||
message: 'Internal Error'
|
||||
});
|
||||
|
||||
expect(result.errorId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
);
|
||||
expect(result.message).toBe('An unexpected error occurred');
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,13 @@ import { env } from 'process';
|
||||
import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime';
|
||||
import { detectLocale } from '$lib/shared/server/locale';
|
||||
|
||||
// VITE_SENTRY_DSN is a write-only ingest key — it can POST events to GlitchTip
|
||||
// but cannot read them. Safe to include in the client bundle per Sentry security model.
|
||||
Sentry.init({
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
environment: import.meta.env.MODE,
|
||||
tracesSampleRate: 1.0,
|
||||
tracesSampleRate: 0.1,
|
||||
sendDefaultPii: false,
|
||||
enabled: !!import.meta.env.VITE_SENTRY_DSN
|
||||
});
|
||||
|
||||
@@ -122,4 +125,7 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||
|
||||
export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide);
|
||||
|
||||
export const handleError = Sentry.handleErrorWithSentry();
|
||||
export const handleError = Sentry.handleErrorWithSentry(() => {
|
||||
const errorId = Sentry.lastEventId() ?? crypto.randomUUID();
|
||||
return { message: 'An unexpected error occurred', errorId };
|
||||
});
|
||||
|
||||
@@ -1,13 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
function copyId() {
|
||||
const id = page.error?.errorId;
|
||||
if (!id) return;
|
||||
if (!navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(id).then(
|
||||
() => {
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
},
|
||||
() => {
|
||||
/* clipboard denied or unavailable — select-all on the <code> element remains */
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.page_title_error()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="px-4 py-12 text-center font-sans">
|
||||
<p class="font-sans text-6xl font-bold text-ink">{page.status}</p>
|
||||
<p class="mt-2 font-sans text-sm text-ink-2">{page.error?.message ?? 'Internal Error'}</p>
|
||||
</div>
|
||||
<main class="px-4 py-12 text-center font-sans">
|
||||
<h1 class="mb-2 font-serif text-2xl font-bold text-ink">{m.page_title_error()}</h1>
|
||||
<p class="mb-8 font-sans text-sm text-ink-2">
|
||||
{page.error?.message ?? m.error_internal_error()}
|
||||
</p>
|
||||
<p class="mb-4 font-mono text-4xl font-bold text-ink">{page.status}</p>
|
||||
|
||||
{#if page.error?.errorId}
|
||||
<div class="mt-6 flex flex-col items-center gap-3">
|
||||
<p class="font-sans text-xs tracking-widest text-ink-2 uppercase">
|
||||
{m.error_page_id_label()}
|
||||
</p>
|
||||
<code
|
||||
class="rounded border border-line bg-surface px-3 py-1 font-mono text-sm text-ink select-all"
|
||||
>
|
||||
{page.error.errorId}
|
||||
</code>
|
||||
<button
|
||||
class="min-h-[44px] min-w-[44px] rounded-sm bg-brand-navy px-5 py-2 font-sans text-sm text-white transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2"
|
||||
onclick={copyId}
|
||||
aria-label={m.error_copy_id_label()}
|
||||
>
|
||||
<span aria-live="polite">{copied ? m.error_copied() : m.error_copy_id_label()}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -24,7 +24,6 @@ export const GET: RequestHandler = async ({ url, fetch }) => {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Tags Data', data);
|
||||
|
||||
// 4. Daten zurück an den Browser schicken
|
||||
return json(data);
|
||||
|
||||
@@ -4,7 +4,10 @@ import { page as browserPage } from 'vitest/browser';
|
||||
|
||||
const mockPage = {
|
||||
status: 500,
|
||||
error: { message: 'Internal Error' } as { message: string } | null
|
||||
error: { message: 'Internal Error', errorId: undefined } as {
|
||||
message: string;
|
||||
errorId?: string;
|
||||
} | null
|
||||
};
|
||||
|
||||
vi.mock('$app/state', () => ({
|
||||
@@ -13,6 +16,16 @@ vi.mock('$app/state', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('$lib/paraglide/messages.js', () => ({
|
||||
m: {
|
||||
page_title_error: () => 'Es ist etwas schiefgelaufen.',
|
||||
error_internal_error: () => 'Ein unerwarteter Fehler ist aufgetreten.',
|
||||
error_page_id_label: () => 'Fehler-ID',
|
||||
error_copy_id_label: () => 'ID kopieren',
|
||||
error_copied: () => 'Kopiert!'
|
||||
}
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
async function loadComponent() {
|
||||
@@ -20,7 +33,7 @@ async function loadComponent() {
|
||||
}
|
||||
|
||||
describe('+error.svelte', () => {
|
||||
it('renders the page status code prominently', async () => {
|
||||
it('renders the page status code', async () => {
|
||||
mockPage.status = 404;
|
||||
mockPage.error = { message: 'Not Found' };
|
||||
|
||||
@@ -40,13 +53,79 @@ describe('+error.svelte', () => {
|
||||
await expect.element(browserPage.getByText('Database unavailable')).toBeVisible();
|
||||
});
|
||||
|
||||
it('falls back to the literal "Internal Error" when page.error is null', async () => {
|
||||
it('falls back to error_internal_error message when page.error is null', async () => {
|
||||
mockPage.status = 500;
|
||||
mockPage.error = null;
|
||||
|
||||
const ErrorPage = await loadComponent();
|
||||
render(ErrorPage);
|
||||
|
||||
await expect.element(browserPage.getByText('Internal Error')).toBeVisible();
|
||||
await expect
|
||||
.element(browserPage.getByText('Ein unerwarteter Fehler ist aufgetreten.'))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it('shows errorId when page.error.errorId is set', async () => {
|
||||
mockPage.status = 500;
|
||||
mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' };
|
||||
|
||||
const ErrorPage = await loadComponent();
|
||||
render(ErrorPage);
|
||||
|
||||
await expect.element(browserPage.getByText('abc-123-def')).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows copy button when errorId is present', async () => {
|
||||
mockPage.status = 500;
|
||||
mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' };
|
||||
|
||||
const ErrorPage = await loadComponent();
|
||||
render(ErrorPage);
|
||||
|
||||
await expect.element(browserPage.getByRole('button', { name: 'ID kopieren' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not render errorId section when errorId is absent', async () => {
|
||||
mockPage.status = 500;
|
||||
mockPage.error = { message: 'Something broke' };
|
||||
|
||||
const ErrorPage = await loadComponent();
|
||||
render(ErrorPage);
|
||||
|
||||
await expect.element(browserPage.getByText('Fehler-ID')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Kopiert!" after clicking the copy button', async () => {
|
||||
mockPage.status = 500;
|
||||
mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' };
|
||||
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
configurable: true,
|
||||
writable: true
|
||||
});
|
||||
|
||||
const ErrorPage = await loadComponent();
|
||||
render(ErrorPage);
|
||||
|
||||
await browserPage.getByRole('button', { name: 'ID kopieren' }).click();
|
||||
await expect.element(browserPage.getByText('Kopiert!')).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not show "Kopiert!" when clipboard write is rejected', async () => {
|
||||
mockPage.status = 500;
|
||||
mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' };
|
||||
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||||
configurable: true,
|
||||
writable: true
|
||||
});
|
||||
|
||||
const ErrorPage = await loadComponent();
|
||||
render(ErrorPage);
|
||||
|
||||
await browserPage.getByRole('button', { name: 'ID kopieren' }).click();
|
||||
await expect.element(browserPage.getByText('Kopiert!')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,8 @@ export default defineConfig({
|
||||
'src/lib/shared/utils/**',
|
||||
'src/lib/shared/server/**',
|
||||
'src/lib/shared/discussion/**',
|
||||
'src/lib/document/**'
|
||||
'src/lib/document/**',
|
||||
'src/hooks.server.ts'
|
||||
],
|
||||
exclude: ['**/*.svelte', '**/*.svelte.ts', '**/__mocks__/**'],
|
||||
thresholds: {
|
||||
|
||||
Reference in New Issue
Block a user