@remit/web-client 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harness/build.mjs +84 -0
- package/harness/index.html +37 -0
- package/harness/vite-preset.ts +29 -0
- package/index.html +47 -0
- package/package.json +147 -0
- package/public/config.js +5 -0
- package/public/locales/de/common.json +1 -0
- package/public/locales/de/errors.json +1 -0
- package/public/locales/de/mail.json +13 -0
- package/public/locales/de/settings.json +1 -0
- package/public/locales/en/common.json +26 -0
- package/public/locales/en/errors.json +1 -0
- package/public/locales/en/mail.json +69 -0
- package/public/locales/en/settings.json +1 -0
- package/public/locales/fr/common.json +1 -0
- package/public/locales/fr/errors.json +1 -0
- package/public/locales/fr/mail.json +13 -0
- package/public/locales/fr/settings.json +1 -0
- package/public/locales/nl/common.json +1 -0
- package/public/locales/nl/errors.json +1 -0
- package/public/locales/nl/mail.json +13 -0
- package/public/locales/nl/settings.json +1 -0
- package/src/auth/AccountMenu.tsx +58 -0
- package/src/auth/AccountSession.tsx +19 -0
- package/src/auth/BetterAuthShell.tsx +198 -0
- package/src/auth/account-menu-mode.test.ts +64 -0
- package/src/auth/account-menu-mode.ts +28 -0
- package/src/auth/amplify-config.test.ts +158 -0
- package/src/auth/amplify-config.ts +60 -0
- package/src/auth/auth-error.test.ts +109 -0
- package/src/auth/auth-error.ts +107 -0
- package/src/auth/auth-interceptor.test.ts +86 -0
- package/src/auth/auth-interceptor.ts +23 -0
- package/src/auth/auth.css +334 -0
- package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
- package/src/auth/better-auth-config.ts +85 -0
- package/src/auth/better-auth-provider.tsx +20 -0
- package/src/auth/cognito/CognitoAccount.tsx +48 -0
- package/src/auth/cognito/CognitoShell.tsx +266 -0
- package/src/auth/cognito/cognito-token.test.ts +110 -0
- package/src/auth/cognito/cognito-token.ts +47 -0
- package/src/auth/cognito/cognito.css +21 -0
- package/src/auth/cognito-provider.tsx +22 -0
- package/src/auth/combined-provider.tsx +42 -0
- package/src/auth/composition.test.ts +136 -0
- package/src/auth/provider.tsx +63 -0
- package/src/auth/sign-out-visibility.test.ts +40 -0
- package/src/auth/sign-out-visibility.ts +10 -0
- package/src/auth/sign-up-disabled.test.ts +23 -0
- package/src/components/compose/AddressField.tsx +237 -0
- package/src/components/compose/ComposeBody.tsx +36 -0
- package/src/components/compose/ComposeForm.tsx +676 -0
- package/src/components/compose/ComposeProvider.tsx +133 -0
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
- package/src/components/compose/FromSelector.tsx +67 -0
- package/src/components/compose/FullCompose.tsx +82 -0
- package/src/components/compose/InlineCompose.tsx +29 -0
- package/src/components/compose/MobileComposeSheet.tsx +152 -0
- package/src/components/compose/PlateEditor.tsx +86 -0
- package/src/components/compose/PlateToolbar.tsx +110 -0
- package/src/components/compose/SubjectField.tsx +19 -0
- package/src/components/compose/draft-discard-banner.test.ts +76 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/sanitize-quote-html.ts +23 -0
- package/src/components/layout/AppShellSkeleton.tsx +53 -0
- package/src/components/layout/ComposeFab.tsx +86 -0
- package/src/components/layout/Drawer.tsx +105 -0
- package/src/components/mail/AutoMovedIndicator.tsx +48 -0
- package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
- package/src/components/mail/BlockedImagesNotice.tsx +41 -0
- package/src/components/mail/BriefPane.tsx +289 -0
- package/src/components/mail/ConversationView.tsx +462 -0
- package/src/components/mail/DailyBrief.tsx +451 -0
- package/src/components/mail/DraftsView.tsx +279 -0
- package/src/components/mail/FlaggedList.tsx +170 -0
- package/src/components/mail/FlaggedPane.tsx +217 -0
- package/src/components/mail/IntelligencePane.test.ts +123 -0
- package/src/components/mail/IntelligencePane.tsx +441 -0
- package/src/components/mail/MailListHeader.tsx +187 -0
- package/src/components/mail/MailNav.tsx +70 -0
- package/src/components/mail/MailSidebarAdapter.tsx +351 -0
- package/src/components/mail/MailViewChrome.tsx +95 -0
- package/src/components/mail/MailboxPane.tsx +1219 -0
- package/src/components/mail/MessageActionMenu.tsx +368 -0
- package/src/components/mail/MessageBody.tsx +207 -0
- package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
- package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
- package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
- package/src/components/mail/MessageCard.tsx +401 -0
- package/src/components/mail/MessageDetail.tsx +153 -0
- package/src/components/mail/MessageList.tsx +804 -0
- package/src/components/mail/MessageListItem.tsx +267 -0
- package/src/components/mail/MessageToolbar.render.test.ts +57 -0
- package/src/components/mail/MessageToolbar.tsx +166 -0
- package/src/components/mail/MobileMessageBar.tsx +142 -0
- package/src/components/mail/MoveToTrigger.tsx +250 -0
- package/src/components/mail/NavMenuButton.tsx +25 -0
- package/src/components/mail/OutboxPane.tsx +531 -0
- package/src/components/mail/PullToRefresh.tsx +24 -0
- package/src/components/mail/RawMessageView.tsx +84 -0
- package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
- package/src/components/mail/SelectionToolbar.tsx +139 -0
- package/src/components/mail/SpamRescue.render.test.ts +35 -0
- package/src/components/mail/SpamRescue.tsx +138 -0
- package/src/components/mail/SwipeableMessageRow.tsx +148 -0
- package/src/components/mail/message-body-error-banner-content.ts +87 -0
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
- package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
- package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
- package/src/components/mail/organize/OrganizePanel.tsx +344 -0
- package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
- package/src/components/settings/AccountFormPanel.test.ts +72 -0
- package/src/components/settings/AccountFormPanel.tsx +942 -0
- package/src/components/settings/DangerZone.tsx +30 -0
- package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
- package/src/components/settings/DeleteAccountDialog.tsx +224 -0
- package/src/components/settings/FiltersList.render.test.ts +71 -0
- package/src/components/settings/FiltersList.tsx +93 -0
- package/src/components/settings/account-form-helpers.test.ts +172 -0
- package/src/components/settings/account-form-helpers.ts +105 -0
- package/src/components/ui/AppVersion.tsx +50 -0
- package/src/components/ui/BugReportButton.tsx +45 -0
- package/src/components/ui/ConfirmDialog.tsx +125 -0
- package/src/components/ui/DropdownMenu.tsx +105 -0
- package/src/components/ui/EmptyState.tsx +11 -0
- package/src/components/ui/ErrorBanner.tsx +88 -0
- package/src/components/ui/ErrorBannerProvider.tsx +77 -0
- package/src/components/ui/ErrorBannerStack.tsx +34 -0
- package/src/components/ui/ErrorState.tsx +80 -0
- package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
- package/src/components/ui/FatalErrorOverlay.tsx +151 -0
- package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
- package/src/components/ui/SlidePanel.tsx +72 -0
- package/src/components/ui/error-banners.test.ts +207 -0
- package/src/components/ui/error-banners.ts +111 -0
- package/src/hooks/queries/keys.ts +38 -0
- package/src/hooks/useArchiveMailbox.ts +104 -0
- package/src/hooks/useAutoMovedBadge.ts +77 -0
- package/src/hooks/useCurrentMailboxName.ts +99 -0
- package/src/hooks/useDebouncedValue.ts +12 -0
- package/src/hooks/useDeleteMessages.test.ts +103 -0
- package/src/hooks/useDeleteMessages.ts +226 -0
- package/src/hooks/useFilters.ts +112 -0
- package/src/hooks/useIntelligenceData.test.ts +269 -0
- package/src/hooks/useIntelligenceData.ts +284 -0
- package/src/hooks/useIsDark.ts +27 -0
- package/src/hooks/useKeyboardNavigation.ts +192 -0
- package/src/hooks/useLayoutTier.test.ts +47 -0
- package/src/hooks/useLayoutTier.ts +44 -0
- package/src/hooks/useLongPress.test.ts +291 -0
- package/src/hooks/useLongPress.ts +87 -0
- package/src/hooks/useMailboxAccount.ts +62 -0
- package/src/hooks/useMailboxNameIndex.ts +32 -0
- package/src/hooks/useMarkAsRead.test.ts +165 -0
- package/src/hooks/useMarkAsRead.ts +319 -0
- package/src/hooks/useMediaQuery.ts +39 -0
- package/src/hooks/useMessageBodyContent.test.ts +370 -0
- package/src/hooks/useMessageBodyContent.ts +380 -0
- package/src/hooks/useMoveMessages.test.ts +63 -0
- package/src/hooks/useMoveMessages.ts +221 -0
- package/src/hooks/useOrganizeJob.ts +90 -0
- package/src/hooks/useOrganizePreview.ts +40 -0
- package/src/hooks/useRescueCandidates.test.ts +217 -0
- package/src/hooks/useRescueCandidates.ts +48 -0
- package/src/hooks/useSaveDraft.ts +118 -0
- package/src/hooks/useSelection.test.ts +75 -0
- package/src/hooks/useSelection.ts +211 -0
- package/src/hooks/useSemanticSearch.ts +94 -0
- package/src/hooks/useSignature.ts +69 -0
- package/src/hooks/useStaleAccountSync.test.ts +211 -0
- package/src/hooks/useStaleAccountSync.ts +246 -0
- package/src/hooks/useSwipeNavigation.test.ts +35 -0
- package/src/hooks/useSwipeNavigation.ts +99 -0
- package/src/hooks/useToggleStar.ts +187 -0
- package/src/hooks/useToggleTrusted.test.ts +102 -0
- package/src/hooks/useToggleTrusted.ts +161 -0
- package/src/hooks/useTriageKeyboard.ts +103 -0
- package/src/hooks/useTriggerSync.test.ts +24 -0
- package/src/hooks/useTriggerSync.ts +58 -0
- package/src/hooks/useUpdateAddressFlags.ts +125 -0
- package/src/hooks/useVisualViewport.test.ts +127 -0
- package/src/hooks/useVisualViewport.ts +51 -0
- package/src/index.css +19 -0
- package/src/lib/account-order.test.ts +84 -0
- package/src/lib/account-order.ts +13 -0
- package/src/lib/adjacent-message.test.ts +43 -0
- package/src/lib/adjacent-message.ts +20 -0
- package/src/lib/api.ts +82 -0
- package/src/lib/app-info.ts +15 -0
- package/src/lib/auto-moved.test.ts +140 -0
- package/src/lib/auto-moved.ts +68 -0
- package/src/lib/autodiscovery.test.ts +94 -0
- package/src/lib/autodiscovery.ts +354 -0
- package/src/lib/brief.test.ts +366 -0
- package/src/lib/brief.ts +180 -0
- package/src/lib/bug-report.test.ts +213 -0
- package/src/lib/bug-report.ts +176 -0
- package/src/lib/client.ts +36 -0
- package/src/lib/console-errors.test.ts +64 -0
- package/src/lib/console-errors.ts +92 -0
- package/src/lib/conversation-target.test.ts +68 -0
- package/src/lib/conversation-target.ts +59 -0
- package/src/lib/display-category.ts +20 -0
- package/src/lib/drafts.test.ts +241 -0
- package/src/lib/drafts.ts +121 -0
- package/src/lib/error-classifier.test.ts +133 -0
- package/src/lib/error-classifier.ts +96 -0
- package/src/lib/fatal-error.test.ts +97 -0
- package/src/lib/fatal-error.ts +165 -0
- package/src/lib/folder-roles.test.ts +113 -0
- package/src/lib/folder-roles.ts +112 -0
- package/src/lib/format.test.ts +115 -0
- package/src/lib/format.ts +198 -0
- package/src/lib/i18n.ts +36 -0
- package/src/lib/intelligence-pref.test.ts +57 -0
- package/src/lib/intelligence-pref.ts +25 -0
- package/src/lib/keymap-dispatch.test.ts +178 -0
- package/src/lib/keymap-dispatch.ts +187 -0
- package/src/lib/keymap.test.ts +49 -0
- package/src/lib/keymap.ts +178 -0
- package/src/lib/mail-context.ts +64 -0
- package/src/lib/mail-route.test.ts +80 -0
- package/src/lib/mail-route.ts +44 -0
- package/src/lib/message-body-source.test.ts +98 -0
- package/src/lib/message-body-source.ts +48 -0
- package/src/lib/move-targets.test.ts +195 -0
- package/src/lib/move-targets.ts +74 -0
- package/src/lib/onboarding-completion.test.ts +64 -0
- package/src/lib/onboarding-completion.ts +23 -0
- package/src/lib/organize/filter-status.test.ts +99 -0
- package/src/lib/organize/filter-status.ts +86 -0
- package/src/lib/organize/organize-copy.test.ts +93 -0
- package/src/lib/organize/organize-copy.ts +61 -0
- package/src/lib/organize/organize-model.test.ts +104 -0
- package/src/lib/organize/organize-model.ts +105 -0
- package/src/lib/organize/organize-poll.test.ts +37 -0
- package/src/lib/organize/organize-poll.ts +28 -0
- package/src/lib/outbox-status.test.ts +64 -0
- package/src/lib/outbox-status.ts +75 -0
- package/src/lib/plate-serializer.ts +164 -0
- package/src/lib/provider-presets.test.ts +96 -0
- package/src/lib/provider-presets.ts +81 -0
- package/src/lib/query-error-handler.test.ts +144 -0
- package/src/lib/query-error-handler.ts +34 -0
- package/src/lib/query-escalation.integration.test.ts +138 -0
- package/src/lib/recent-searches.test.ts +53 -0
- package/src/lib/recent-searches.ts +34 -0
- package/src/lib/rescue-candidates.test.ts +94 -0
- package/src/lib/rescue-candidates.ts +35 -0
- package/src/lib/rescue-telemetry.test.ts +75 -0
- package/src/lib/rescue-telemetry.ts +55 -0
- package/src/lib/rum-adapter.test.ts +299 -0
- package/src/lib/rum-adapter.ts +107 -0
- package/src/lib/saved-searches.test.ts +80 -0
- package/src/lib/saved-searches.ts +52 -0
- package/src/lib/search-pending.test.ts +61 -0
- package/src/lib/search-pending.ts +24 -0
- package/src/lib/search-query.test.ts +35 -0
- package/src/lib/search-query.ts +11 -0
- package/src/lib/search-result.test.ts +160 -0
- package/src/lib/search-result.ts +102 -0
- package/src/lib/search-schemas.ts +34 -0
- package/src/lib/search-token-index.test.ts +112 -0
- package/src/lib/search-token-index.ts +56 -0
- package/src/lib/search-tokens.test.ts +218 -0
- package/src/lib/search-tokens.ts +133 -0
- package/src/lib/telemetry-context.tsx +9 -0
- package/src/lib/telemetry.ts +17 -0
- package/src/lib/theme-preference.ts +46 -0
- package/src/lib/theme.ts +33 -0
- package/src/lib/utils.ts +4 -0
- package/src/main.tsx +10 -0
- package/src/routeTree.gen.ts +392 -0
- package/src/router.test.ts +81 -0
- package/src/router.tsx +31 -0
- package/src/routes/__root.tsx +69 -0
- package/src/routes/index.tsx +7 -0
- package/src/routes/mail/$mailboxId.tsx +27 -0
- package/src/routes/mail/flagged.tsx +37 -0
- package/src/routes/mail/index.tsx +41 -0
- package/src/routes/mail/outbox.tsx +13 -0
- package/src/routes/mail.tsx +413 -0
- package/src/routes/onboarding.tsx +35 -0
- package/src/routes/settings/-accounts.test.ts +65 -0
- package/src/routes/settings/accounts.tsx +542 -0
- package/src/routes/settings/advanced.tsx +55 -0
- package/src/routes/settings/appearance.tsx +140 -0
- package/src/routes/settings/filters.tsx +141 -0
- package/src/routes/settings/folders.tsx +216 -0
- package/src/routes/settings/index.tsx +5 -0
- package/src/routes/settings/senders.tsx +466 -0
- package/src/routes/settings/suggested-vips.tsx +10 -0
- package/src/routes/settings.tsx +66 -0
- package/src/runtime-config.ts +75 -0
- package/src/shell/index.tsx +98 -0
- package/src/types/index.ts +73 -0
- package/src/vite-env.d.ts +4 -0
- package/vite.base.ts +43 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { AlertCircle } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
interface ErrorStateProps {
|
|
4
|
+
title?: string;
|
|
5
|
+
error: unknown;
|
|
6
|
+
onRetry?: () => void;
|
|
7
|
+
variant?: "block" | "inline";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const formatErrorMessage = (error: unknown): string => {
|
|
11
|
+
if (error instanceof Error) return error.message;
|
|
12
|
+
if (typeof error === "string") return error;
|
|
13
|
+
if (
|
|
14
|
+
typeof error === "object" &&
|
|
15
|
+
error !== null &&
|
|
16
|
+
"message" in error &&
|
|
17
|
+
typeof (error as { message: unknown }).message === "string"
|
|
18
|
+
) {
|
|
19
|
+
return (error as { message: string }).message;
|
|
20
|
+
}
|
|
21
|
+
return "An unexpected error occurred";
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const ErrorState = ({
|
|
25
|
+
title = "Couldn't load content",
|
|
26
|
+
error,
|
|
27
|
+
onRetry,
|
|
28
|
+
variant = "block",
|
|
29
|
+
}: ErrorStateProps) => {
|
|
30
|
+
const message = formatErrorMessage(error);
|
|
31
|
+
|
|
32
|
+
if (variant === "inline") {
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
role="alert"
|
|
36
|
+
className="flex items-start gap-2 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm"
|
|
37
|
+
>
|
|
38
|
+
<AlertCircle
|
|
39
|
+
className="size-4 shrink-0 text-danger mt-0.5"
|
|
40
|
+
aria-hidden="true"
|
|
41
|
+
/>
|
|
42
|
+
<div className="flex-1 min-w-0">
|
|
43
|
+
<p className="font-medium text-danger">{title}</p>
|
|
44
|
+
<p className="text-fg-muted mt-0.5 break-words">{message}</p>
|
|
45
|
+
</div>
|
|
46
|
+
{onRetry && (
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
onClick={onRetry}
|
|
50
|
+
className="shrink-0 text-sm font-medium text-accent hover:underline"
|
|
51
|
+
>
|
|
52
|
+
Retry
|
|
53
|
+
</button>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
role="alert"
|
|
62
|
+
className="flex flex-col items-center justify-center gap-3 p-8 text-center"
|
|
63
|
+
>
|
|
64
|
+
<AlertCircle className="size-8 text-danger" aria-hidden="true" />
|
|
65
|
+
<div>
|
|
66
|
+
<p className="font-medium text-danger">{title}</p>
|
|
67
|
+
<p className="text-sm text-fg-muted mt-1 break-words">{message}</p>
|
|
68
|
+
</div>
|
|
69
|
+
{onRetry && (
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={onRetry}
|
|
73
|
+
className="inline-flex items-center rounded-md border border-line bg-canvas px-4 py-2 text-sm font-medium hover:bg-surface-raised transition-colors"
|
|
74
|
+
>
|
|
75
|
+
Retry
|
|
76
|
+
</button>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import type { FatalError } from "@/lib/fatal-error";
|
|
6
|
+
import { FatalErrorScreen } from "./FatalErrorOverlay";
|
|
7
|
+
|
|
8
|
+
const recoverableFatal: FatalError = {
|
|
9
|
+
error: new Error("Request failed with status 500"),
|
|
10
|
+
message: "Request failed with status 500",
|
|
11
|
+
correlationId: "abc-123",
|
|
12
|
+
at: 0,
|
|
13
|
+
recoverable: true,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const deterministicFatal: FatalError = {
|
|
17
|
+
error: new Error("date value is not finite in DateTimeFormat format()"),
|
|
18
|
+
message: "date value is not finite in DateTimeFormat format()",
|
|
19
|
+
correlationId: "def-456",
|
|
20
|
+
at: 0,
|
|
21
|
+
recoverable: false,
|
|
22
|
+
stack: "Error: date value is not finite\n at format",
|
|
23
|
+
componentStack: "\n at AccountsSettings\n at Route",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const render = (fatal: FatalError): string =>
|
|
27
|
+
renderToString(createElement(FatalErrorScreen, { fatal }) as never);
|
|
28
|
+
|
|
29
|
+
describe("FatalErrorScreen — the full-screen red escalation page", () => {
|
|
30
|
+
it("renders a loud red full-screen overlay (not a benign grey label)", () => {
|
|
31
|
+
const html = render(recoverableFatal);
|
|
32
|
+
assert.match(html, /data-testid="fatal-error-overlay"/);
|
|
33
|
+
assert.match(html, /bg-red-700/);
|
|
34
|
+
assert.match(html, /fixed inset-0/);
|
|
35
|
+
assert.match(html, /role="alert"/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("shows the error message and correlation reference", () => {
|
|
39
|
+
const html = render(recoverableFatal);
|
|
40
|
+
assert.match(html, /Request failed with status 500/);
|
|
41
|
+
assert.match(html, /abc-123/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("always offers Report a bug and Copy full details", () => {
|
|
45
|
+
for (const fatal of [recoverableFatal, deterministicFatal]) {
|
|
46
|
+
const html = render(fatal);
|
|
47
|
+
assert.match(html, /Report a bug/);
|
|
48
|
+
assert.match(html, /Copy full details/);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("FatalErrorScreen — recoverable vs fatal affordances", () => {
|
|
54
|
+
it("a recoverable error offers Retry and no dead-end safe-route link", () => {
|
|
55
|
+
const html = render(recoverableFatal);
|
|
56
|
+
assert.match(html, /Retry/);
|
|
57
|
+
assert.doesNotMatch(html, /Go to inbox/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("a deterministic fatal offers a safe route out — never a re-crashing Retry", () => {
|
|
61
|
+
const html = render(deterministicFatal);
|
|
62
|
+
assert.doesNotMatch(html, />Retry</);
|
|
63
|
+
assert.match(html, /Go to inbox/);
|
|
64
|
+
assert.match(html, /href="\/mail"/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { AlertOctagon } from "lucide-react";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
type BugReportSeed,
|
|
5
|
+
buildBugReportContext,
|
|
6
|
+
buildBugReportDetails,
|
|
7
|
+
buildGitHubIssueUrl,
|
|
8
|
+
} from "@/lib/bug-report";
|
|
9
|
+
import {
|
|
10
|
+
type FatalError,
|
|
11
|
+
getCurrentFatalError,
|
|
12
|
+
subscribeFatalError,
|
|
13
|
+
} from "@/lib/fatal-error";
|
|
14
|
+
|
|
15
|
+
/** Safe route to escape a deterministically-crashing page. `/` redirects here. */
|
|
16
|
+
const SAFE_ROUTE = "/mail";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The full-screen red escalation page. A first-party fatal — an escalated 5xx,
|
|
20
|
+
* or a render exception caught by the route error boundary — takes over the
|
|
21
|
+
* entire viewport here: loud, red, impossible to miss. Never a benign grey
|
|
22
|
+
* label.
|
|
23
|
+
*
|
|
24
|
+
* It is intentionally NOT dismissible: a fatal means the app is in an unknown
|
|
25
|
+
* state. The affordances depend on whether the error is recoverable:
|
|
26
|
+
* - recoverable (transient 5xx / network / abort) → Retry the same action.
|
|
27
|
+
* - fatal (deterministic render exception) → NO Retry (it re-crashes); offer a
|
|
28
|
+
* way out to a safe route instead.
|
|
29
|
+
* Both states can Report a bug (prefilled with the stacktrace) and Copy full
|
|
30
|
+
* details.
|
|
31
|
+
*/
|
|
32
|
+
export const FatalErrorOverlay = () => {
|
|
33
|
+
const [fatal, setFatal] = useState<FatalError | null>(() =>
|
|
34
|
+
getCurrentFatalError(),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
useEffect(() => subscribeFatalError(setFatal), []);
|
|
38
|
+
|
|
39
|
+
if (!fatal) return null;
|
|
40
|
+
|
|
41
|
+
return <FatalErrorScreen fatal={fatal} />;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
interface FatalErrorScreenProps {
|
|
45
|
+
fatal: FatalError;
|
|
46
|
+
/**
|
|
47
|
+
* What Retry does for a recoverable error. The route error boundary passes
|
|
48
|
+
* TanStack's `reset`; without it, Retry reloads the page. Ignored for a
|
|
49
|
+
* non-recoverable fatal (no Retry is shown).
|
|
50
|
+
*/
|
|
51
|
+
onRetry?: () => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const seedFromFatal = (fatal: FatalError): BugReportSeed => ({
|
|
55
|
+
errorMessage: fatal.message,
|
|
56
|
+
stack: fatal.stack,
|
|
57
|
+
componentStack: fatal.componentStack,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Presentational screen — exported so it can be rendered directly in tests and
|
|
62
|
+
* by the route-level error boundary without going through the subscription.
|
|
63
|
+
*/
|
|
64
|
+
export const FatalErrorScreen = ({ fatal, onRetry }: FatalErrorScreenProps) => {
|
|
65
|
+
const [copied, setCopied] = useState(false);
|
|
66
|
+
|
|
67
|
+
const handleReload = () => {
|
|
68
|
+
window.location.reload();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleRetry = () => {
|
|
72
|
+
if (onRetry) {
|
|
73
|
+
onRetry();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
handleReload();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const handleReportBug = () => {
|
|
80
|
+
const url = buildGitHubIssueUrl(
|
|
81
|
+
buildBugReportContext(seedFromFatal(fatal)),
|
|
82
|
+
);
|
|
83
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const handleCopyDetails = async () => {
|
|
87
|
+
const details = buildBugReportDetails(
|
|
88
|
+
buildBugReportContext(seedFromFatal(fatal)),
|
|
89
|
+
);
|
|
90
|
+
await navigator.clipboard.writeText(details);
|
|
91
|
+
setCopied(true);
|
|
92
|
+
window.setTimeout(() => setCopied(false), 2000);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const description = fatal.recoverable
|
|
96
|
+
? "The server returned an unexpected error. This is not your fault — nothing was saved or sent. Retry, or report a bug so we can fix it."
|
|
97
|
+
: "Something went wrong and this page can't recover. This is not your fault — nothing was saved or sent. Go to your inbox, or report a bug so we can fix it.";
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
role="alert"
|
|
102
|
+
aria-live="assertive"
|
|
103
|
+
data-testid="fatal-error-overlay"
|
|
104
|
+
className="fixed inset-0 z-[1000] flex flex-col items-center justify-center gap-6 bg-red-700 p-6 text-center text-white"
|
|
105
|
+
>
|
|
106
|
+
<AlertOctagon className="size-16 shrink-0" aria-hidden="true" />
|
|
107
|
+
<div className="max-w-lg space-y-3">
|
|
108
|
+
<h1 className="text-2xl font-bold tracking-tight">Something broke</h1>
|
|
109
|
+
<p className="text-base text-red-50">{description}</p>
|
|
110
|
+
<p className="break-words font-mono text-sm text-red-100">
|
|
111
|
+
{fatal.message}
|
|
112
|
+
</p>
|
|
113
|
+
<p className="font-mono text-xs text-red-200">
|
|
114
|
+
Reference: {fatal.correlationId}
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
<div className="flex flex-wrap items-center justify-center gap-3">
|
|
118
|
+
{fatal.recoverable ? (
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
onClick={handleRetry}
|
|
122
|
+
className="inline-flex min-h-11 items-center rounded-md bg-white px-5 text-sm font-semibold text-red-700 transition-colors hover:bg-red-50"
|
|
123
|
+
>
|
|
124
|
+
Retry
|
|
125
|
+
</button>
|
|
126
|
+
) : (
|
|
127
|
+
<a
|
|
128
|
+
href={SAFE_ROUTE}
|
|
129
|
+
className="inline-flex min-h-11 items-center rounded-md bg-white px-5 text-sm font-semibold text-red-700 transition-colors hover:bg-red-50"
|
|
130
|
+
>
|
|
131
|
+
Go to inbox
|
|
132
|
+
</a>
|
|
133
|
+
)}
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onClick={handleReportBug}
|
|
137
|
+
className="inline-flex min-h-11 items-center rounded-md border border-white/70 px-5 text-sm font-semibold text-white transition-colors hover:bg-white/10"
|
|
138
|
+
>
|
|
139
|
+
Report a bug
|
|
140
|
+
</button>
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
onClick={handleCopyDetails}
|
|
144
|
+
className="inline-flex min-h-11 items-center rounded-md border border-white/70 px-5 text-sm font-semibold text-white transition-colors hover:bg-white/10"
|
|
145
|
+
>
|
|
146
|
+
{copied ? "Copied" : "Copy full details"}
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Kbd } from "@remit/ui";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import { Fragment, useCallback, useEffect } from "react";
|
|
4
|
+
import { KEY_HINT_GROUPS } from "@/lib/keymap";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
interface KeyboardShortcutsModalProps {
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The `?` help overlay (#429). Renders the full triage key map from the single
|
|
14
|
+
* source of truth (`@/lib/keymap`) using the remit-ui `Kbd` component, so the
|
|
15
|
+
* displayed bindings can never drift from what the dispatcher actually routes.
|
|
16
|
+
*/
|
|
17
|
+
export const KeyboardShortcutsModal = ({
|
|
18
|
+
isOpen,
|
|
19
|
+
onClose,
|
|
20
|
+
}: KeyboardShortcutsModalProps) => {
|
|
21
|
+
// Close on Escape
|
|
22
|
+
const handleKeyDown = useCallback(
|
|
23
|
+
(event: KeyboardEvent) => {
|
|
24
|
+
if (event.key === "Escape") {
|
|
25
|
+
event.preventDefault();
|
|
26
|
+
onClose();
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
[onClose],
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!isOpen) return;
|
|
34
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
35
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
36
|
+
}, [isOpen, handleKeyDown]);
|
|
37
|
+
|
|
38
|
+
if (!isOpen) return null;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
43
|
+
role="dialog"
|
|
44
|
+
aria-modal="true"
|
|
45
|
+
aria-label="Keyboard shortcuts"
|
|
46
|
+
onClick={onClose}
|
|
47
|
+
onKeyDown={(e) => e.key === "Escape" && onClose()}
|
|
48
|
+
>
|
|
49
|
+
{/* Backdrop */}
|
|
50
|
+
<div className="absolute inset-0 bg-canvas/80 backdrop-blur-sm" />
|
|
51
|
+
|
|
52
|
+
{/* Modal */}
|
|
53
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: stops event propagation from the modal content; keyboard is handled by the outer wrapper */}
|
|
54
|
+
<div
|
|
55
|
+
className={cn(
|
|
56
|
+
"relative z-10 max-h-[85vh] w-full max-w-2xl overflow-y-auto",
|
|
57
|
+
"rounded-lg border border-line bg-surface shadow-lg",
|
|
58
|
+
"p-6",
|
|
59
|
+
)}
|
|
60
|
+
onClick={(e) => e.stopPropagation()}
|
|
61
|
+
onKeyDown={(e) => {
|
|
62
|
+
if (e.key !== "Escape") e.stopPropagation();
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{/* Header */}
|
|
66
|
+
<div className="mb-6 flex items-center justify-between">
|
|
67
|
+
<h2 className="text-lg font-semibold">Keyboard shortcuts</h2>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={onClose}
|
|
71
|
+
className="p-1 text-fg-muted transition-colors hover:text-fg"
|
|
72
|
+
aria-label="Close"
|
|
73
|
+
>
|
|
74
|
+
<X className="size-5" />
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{/* Sections — two columns on wider modals */}
|
|
79
|
+
<div className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
|
|
80
|
+
{KEY_HINT_GROUPS.map((group) => (
|
|
81
|
+
<div key={group.title}>
|
|
82
|
+
<h3 className="mb-3 text-sm font-medium text-fg-muted">
|
|
83
|
+
{group.title}
|
|
84
|
+
</h3>
|
|
85
|
+
<div className="space-y-2">
|
|
86
|
+
{group.hints.map((hint) => (
|
|
87
|
+
<div
|
|
88
|
+
key={`${hint.action}-${hint.keys.join("+")}`}
|
|
89
|
+
className="flex items-center justify-between gap-4"
|
|
90
|
+
>
|
|
91
|
+
<span className="text-sm">{hint.description}</span>
|
|
92
|
+
<span className="flex shrink-0 items-center gap-1">
|
|
93
|
+
{hint.keys.map((key, index) => (
|
|
94
|
+
<Fragment key={key}>
|
|
95
|
+
{index > 0 && hint.keys[0] === "g" && (
|
|
96
|
+
<span className="text-2xs text-fg-subtle">
|
|
97
|
+
then
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
<Kbd>{key}</Kbd>
|
|
101
|
+
</Fragment>
|
|
102
|
+
))}
|
|
103
|
+
</span>
|
|
104
|
+
</div>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
))}
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{/* Footer */}
|
|
112
|
+
<div className="mt-6 border-t border-line pt-4">
|
|
113
|
+
<p className="text-center text-xs text-fg-muted">
|
|
114
|
+
Press <Kbd>Esc</Kbd> to close
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
|
|
5
|
+
interface SlidePanelProps {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
title: string;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
footer?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const SlidePanel = ({
|
|
14
|
+
isOpen,
|
|
15
|
+
onClose,
|
|
16
|
+
title,
|
|
17
|
+
children,
|
|
18
|
+
footer,
|
|
19
|
+
}: SlidePanelProps) => (
|
|
20
|
+
<>
|
|
21
|
+
{/* Backdrop overlay */}
|
|
22
|
+
{/* biome-ignore lint/a11y/useSemanticElements: full-screen backdrop overlay; <button> default styles would break fixed inset positioning */}
|
|
23
|
+
<div
|
|
24
|
+
className={cn(
|
|
25
|
+
"fixed inset-0 bg-black/30 z-40 transition-opacity",
|
|
26
|
+
isOpen ? "opacity-100" : "opacity-0 pointer-events-none",
|
|
27
|
+
)}
|
|
28
|
+
onClick={onClose}
|
|
29
|
+
onKeyDown={(e) => e.key === "Escape" && onClose()}
|
|
30
|
+
role="button"
|
|
31
|
+
tabIndex={0}
|
|
32
|
+
aria-label="Close panel"
|
|
33
|
+
/>
|
|
34
|
+
|
|
35
|
+
{/* Slide-in panel */}
|
|
36
|
+
<div
|
|
37
|
+
className={cn(
|
|
38
|
+
"fixed top-0 right-0 h-full w-full sm:w-[400px] sm:max-w-[90vw] bg-canvas border-l border-line shadow-xl z-50",
|
|
39
|
+
"transform transition-transform duration-200 ease-out",
|
|
40
|
+
isOpen ? "translate-x-0" : "translate-x-full",
|
|
41
|
+
)}
|
|
42
|
+
role="dialog"
|
|
43
|
+
aria-modal="true"
|
|
44
|
+
aria-labelledby="slide-panel-title"
|
|
45
|
+
>
|
|
46
|
+
{/* Panel header */}
|
|
47
|
+
<div className="flex items-center justify-between px-4 h-14 border-b border-line">
|
|
48
|
+
<h2 id="slide-panel-title" className="font-semibold">
|
|
49
|
+
{title}
|
|
50
|
+
</h2>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={onClose}
|
|
54
|
+
className="p-1.5 rounded-md hover:bg-surface-raised transition-colors"
|
|
55
|
+
aria-label="Close"
|
|
56
|
+
>
|
|
57
|
+
<X className="size-5" />
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
{/* Panel content */}
|
|
62
|
+
<div className="h-[calc(100%-3.5rem)] flex flex-col">
|
|
63
|
+
<div className="flex-1 overflow-auto p-4">{children}</div>
|
|
64
|
+
{footer && (
|
|
65
|
+
<div className="flex justify-end gap-3 p-4 border-t border-line bg-canvas">
|
|
66
|
+
{footer}
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
appendBanner,
|
|
5
|
+
buildEntry,
|
|
6
|
+
buildMutationErrorBanner,
|
|
7
|
+
dismissBanner,
|
|
8
|
+
type ErrorBannerEntry,
|
|
9
|
+
formatErrorDetail,
|
|
10
|
+
isMessageNotFoundError,
|
|
11
|
+
} from "./error-banners.js";
|
|
12
|
+
|
|
13
|
+
const make = (
|
|
14
|
+
overrides: Partial<ErrorBannerEntry> & { id: string },
|
|
15
|
+
): ErrorBannerEntry => ({
|
|
16
|
+
severity: "error",
|
|
17
|
+
title: "default",
|
|
18
|
+
detail: undefined,
|
|
19
|
+
createdAt: 0,
|
|
20
|
+
...overrides,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("buildEntry", () => {
|
|
24
|
+
test("defaults to error severity", () => {
|
|
25
|
+
const entry = buildEntry({ title: "boom" }, "id-1", 1000);
|
|
26
|
+
assert.equal(entry.severity, "error");
|
|
27
|
+
assert.equal(entry.title, "boom");
|
|
28
|
+
assert.equal(entry.id, "id-1");
|
|
29
|
+
assert.equal(entry.createdAt, 1000);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("preserves explicit severity and detail", () => {
|
|
33
|
+
const entry = buildEntry(
|
|
34
|
+
{ severity: "warning", title: "watch", detail: "context" },
|
|
35
|
+
"id-2",
|
|
36
|
+
2000,
|
|
37
|
+
);
|
|
38
|
+
assert.equal(entry.severity, "warning");
|
|
39
|
+
assert.equal(entry.detail, "context");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("appendBanner", () => {
|
|
44
|
+
test("adds an entry at the end of the stack", () => {
|
|
45
|
+
const a = make({ id: "1", title: "first" });
|
|
46
|
+
const b = make({ id: "2", title: "second" });
|
|
47
|
+
const got = appendBanner([a], b);
|
|
48
|
+
assert.deepStrictEqual(
|
|
49
|
+
got.map((e) => e.id),
|
|
50
|
+
["1", "2"],
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("dedupes entries with the same severity, title and detail", () => {
|
|
55
|
+
const a = make({
|
|
56
|
+
id: "1",
|
|
57
|
+
title: "Couldn't mark as read",
|
|
58
|
+
detail: "Network error",
|
|
59
|
+
});
|
|
60
|
+
const b = make({
|
|
61
|
+
id: "2",
|
|
62
|
+
title: "Couldn't mark as read",
|
|
63
|
+
detail: "Network error",
|
|
64
|
+
});
|
|
65
|
+
const got = appendBanner([a], b);
|
|
66
|
+
assert.equal(got.length, 1);
|
|
67
|
+
assert.equal(got[0]?.id, "2");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("keeps differing detail as separate banners", () => {
|
|
71
|
+
const a = make({
|
|
72
|
+
id: "1",
|
|
73
|
+
title: "Couldn't mark as read",
|
|
74
|
+
detail: "Network error",
|
|
75
|
+
});
|
|
76
|
+
const b = make({
|
|
77
|
+
id: "2",
|
|
78
|
+
title: "Couldn't mark as read",
|
|
79
|
+
detail: "Server error",
|
|
80
|
+
});
|
|
81
|
+
const got = appendBanner([a], b);
|
|
82
|
+
assert.equal(got.length, 2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("caps the stack at 5 by dropping the oldest", () => {
|
|
86
|
+
let stack: ErrorBannerEntry[] = [];
|
|
87
|
+
for (let i = 0; i < 7; i++) {
|
|
88
|
+
stack = appendBanner(
|
|
89
|
+
stack,
|
|
90
|
+
make({ id: `${i}`, title: `t${i}`, createdAt: i }),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
assert.equal(stack.length, 5);
|
|
94
|
+
assert.deepStrictEqual(
|
|
95
|
+
stack.map((e) => e.id),
|
|
96
|
+
["2", "3", "4", "5", "6"],
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("dismissBanner", () => {
|
|
102
|
+
test("removes the matching entry and leaves the rest in order", () => {
|
|
103
|
+
const stack = [make({ id: "1" }), make({ id: "2" }), make({ id: "3" })];
|
|
104
|
+
const got = dismissBanner(stack, "2");
|
|
105
|
+
assert.deepStrictEqual(
|
|
106
|
+
got.map((e) => e.id),
|
|
107
|
+
["1", "3"],
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("returns the same shape when the id is unknown", () => {
|
|
112
|
+
const stack = [make({ id: "1" })];
|
|
113
|
+
const got = dismissBanner(stack, "nope");
|
|
114
|
+
assert.equal(got.length, 1);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("formatErrorDetail", () => {
|
|
119
|
+
test("returns the message of an Error", () => {
|
|
120
|
+
assert.equal(formatErrorDetail(new Error("boom")), "boom");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("returns a non-empty string as-is", () => {
|
|
124
|
+
assert.equal(formatErrorDetail("nope"), "nope");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("returns undefined for empty strings", () => {
|
|
128
|
+
assert.equal(formatErrorDetail(""), undefined);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("reads object .message", () => {
|
|
132
|
+
assert.equal(formatErrorDetail({ message: "shape" }), "shape");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("returns undefined for null/undefined", () => {
|
|
136
|
+
assert.equal(formatErrorDetail(null), undefined);
|
|
137
|
+
assert.equal(formatErrorDetail(undefined), undefined);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("returns undefined when nothing matches", () => {
|
|
141
|
+
assert.equal(formatErrorDetail(42), undefined);
|
|
142
|
+
assert.equal(formatErrorDetail({}), undefined);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("isMessageNotFoundError (#212)", () => {
|
|
147
|
+
// Regression: clicking a stale inbox row used to hit `describeMessage`
|
|
148
|
+
// and surface the raw "Message not found: <id>" string. The frontend now
|
|
149
|
+
// detects that error shape and shows a "deleted" empty state.
|
|
150
|
+
|
|
151
|
+
test("matches the backend's NotFoundError JSON body", () => {
|
|
152
|
+
assert.equal(
|
|
153
|
+
isMessageNotFoundError({
|
|
154
|
+
message: "Message not found: alice-msg-aaaaaaa",
|
|
155
|
+
}),
|
|
156
|
+
true,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("matches an Error instance with the same message", () => {
|
|
161
|
+
assert.equal(
|
|
162
|
+
isMessageNotFoundError(new Error("Message not found: bob-msg-aaaaaaa")),
|
|
163
|
+
true,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("does not match unrelated errors", () => {
|
|
168
|
+
assert.equal(
|
|
169
|
+
isMessageNotFoundError({ message: "Mailbox not found: x" }),
|
|
170
|
+
false,
|
|
171
|
+
);
|
|
172
|
+
assert.equal(isMessageNotFoundError(new Error("boom")), false);
|
|
173
|
+
assert.equal(isMessageNotFoundError(null), false);
|
|
174
|
+
assert.equal(isMessageNotFoundError(undefined), false);
|
|
175
|
+
assert.equal(isMessageNotFoundError({}), false);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("buildMutationErrorBanner — previously-silent mutations now surface", () => {
|
|
180
|
+
test("uses the server error message when present (real detail, not a dot)", () => {
|
|
181
|
+
const banner = buildMutationErrorBanner(
|
|
182
|
+
"Couldn't save signature",
|
|
183
|
+
"The signature wasn't saved.",
|
|
184
|
+
new Error("Address already in use"),
|
|
185
|
+
);
|
|
186
|
+
assert.equal(banner.title, "Couldn't save signature");
|
|
187
|
+
assert.equal(banner.detail, "Address already in use");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("falls back to a human message when the error carries none", () => {
|
|
191
|
+
const banner = buildMutationErrorBanner(
|
|
192
|
+
"Couldn't discard draft",
|
|
193
|
+
"The draft wasn't deleted.",
|
|
194
|
+
{},
|
|
195
|
+
);
|
|
196
|
+
assert.equal(banner.detail, "The draft wasn't deleted.");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("reads .message off a plain object error body", () => {
|
|
200
|
+
const banner = buildMutationErrorBanner(
|
|
201
|
+
"Couldn't resend message",
|
|
202
|
+
"The message wasn't sent.",
|
|
203
|
+
{ message: "SMTP relay refused" },
|
|
204
|
+
);
|
|
205
|
+
assert.equal(banner.detail, "SMTP relay refused");
|
|
206
|
+
});
|
|
207
|
+
});
|