From 4129f069f658d2da701bab79266f8204d77e2446 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 20:18:17 +0000 Subject: [PATCH 1/5] Say why AI requests fail, offer the next step, and measure activation ai_request_failed now carries tool, error_code, http_status, stage, streaming and duration_ms, mapped from the client's AI error classes. Each tool's error offers the matching next step: the plan dialog for a limit, Sign in again for a refused session, Retry where sending again can work. Every lazy component and route loader goes through loadChunk, which waits for the stale-chunk reload instead of destructuring a module that loaded as nothing (the /login crash), and reloads once per path when a chunk is missing. New client events: signup_completed, trial_started, paywall_shown, first_ai_success and welcome_completed, through the existing telemetry schema. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 4 + src/App.tsx | 80 +++++--- src/components/WebTelemetry.tsx | 7 +- src/components/auth/SignInAgainButton.tsx | 28 +++ src/components/features/ask/AskChat.tsx | 5 +- .../features/sparring/SparringRound.tsx | 12 +- src/components/features/tools/tool-run.tsx | 15 +- src/hooks/use-subscription-error.ts | 11 +- src/lib/activation-events.ts | 116 +++++++++++ src/lib/ai/failure-reason.test.ts | 180 ++++++++++++++++++ src/lib/ai/failure-reason.ts | 90 +++++++++ src/lib/ai/request.ts | 21 +- src/lib/ai/streaming.ts | 9 +- src/lib/lazy-chunk.ts | 58 ++++++ src/lib/observability.ts | 31 ++- src/lib/reload-stale-chunk.ts | 55 ++++-- src/lib/route-modules.ts | 6 +- src/lib/supabase/session-expiry.ts | 10 + src/locales/en.json | 3 +- src/locales/es.json | 3 +- src/locales/hu.json | 3 +- src/pages/AskPage.tsx | 7 +- src/pages/AttackPage.tsx | 7 +- src/pages/BrainstormPage.tsx | 7 +- src/pages/CrossfirePage.tsx | 30 ++- src/pages/FactoidsPage.tsx | 7 +- src/pages/GeneratePage.tsx | 12 +- src/pages/SparringPage.tsx | 10 +- src/test/lib/activation-events.test.ts | 80 ++++++++ src/test/lib/lazy-chunk.test.ts | 58 ++++++ src/test/lib/reload-stale-chunk.test.ts | 8 +- 31 files changed, 875 insertions(+), 98 deletions(-) create mode 100644 src/components/auth/SignInAgainButton.tsx create mode 100644 src/lib/activation-events.ts create mode 100644 src/lib/ai/failure-reason.test.ts create mode 100644 src/lib/ai/failure-reason.ts create mode 100644 src/lib/lazy-chunk.ts create mode 100644 src/test/lib/activation-events.test.ts create mode 100644 src/test/lib/lazy-chunk.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 6f8025ee7..f53c46246 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -170,6 +170,10 @@ A run that produces nothing costs nothing. When an `ai` request fails on the ser Client startup loads Sentry and PostHog asynchronously when configured. Product events and exceptions use the privacy-filtering facade in [`src/lib/observability.ts`](../src/lib/observability.ts), so SDK loading or failure cannot block rendering. Sentry events carry the build's commit SHA as the release and the Vercel environment name. Route crashes keep the original error type and stack frames but replace the message, which can carry user text. Vercel Analytics and Speed Insights run only in the web build and load outside the application render boundary. +The facade accepts only the event names and property values listed in it. `ai_request_failed` says why a request failed: `tool`, `error_code` (one of `limit_reached`, `model_restricted`, `auth_expired`, `timeout`, `network`, `aborted`, `provider_error`, `invalid_response`, `server_error`, `unknown`, mapped from the client's AI error classes in [`src/lib/ai/failure-reason.ts`](../src/lib/ai/failure-reason.ts)), `http_status`, `stage` (`request`, `stream`, `parse`), `streaming` and `duration_ms`. The same file decides each failure's next step: the plan dialog, Sign in again, or Retry. The activation events in [`src/lib/activation-events.ts`](../src/lib/activation-events.ts) are `signup_completed` (`method`), `trial_started`, `paywall_shown` (`reason`, `tool`), `first_ai_success` (`tool`) and `welcome_completed`. The once-per-account ones remember what they sent in this browser's local storage. PostHog keeps its own state in memory and is never told who the user is, so it cannot join one student's events across page loads. + +Every `React.lazy` component and route loader goes through `loadChunk` in [`src/lib/lazy-chunk.ts`](../src/lib/lazy-chunk.ts). After a deploy removes an old file, the page reloads once per path (guarded in `sessionStorage`, shared with the `vite:preloadError` listener) instead of crashing on a module that loaded as nothing. + CI and staging must not upload authenticated browser captures. Logging must not expose tokens, provider keys, user content, or protected staging values. ## PWA and offline behavior diff --git a/src/App.tsx b/src/App.tsx index 3303cd74a..d043929de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ // src/App.tsx import { Owl } from "@/components/brand/Owl"; import { buttonVariants } from "@/components/ui/button"; -import { Component, Suspense, lazy, useEffect, useRef, useState, type ComponentType, type ErrorInfo, type ReactNode } from "react"; +import { Component, Suspense, useEffect, useRef, useState, type ComponentType, type ErrorInfo, type ReactNode } from "react"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import { QueryClientProvider } from "@tanstack/react-query"; @@ -19,6 +19,9 @@ import { focusCurrentDebate, noteTourChunkFailure, tourChunkGaveUp } from "@/lib import { OfflineIndicator } from "@/components/OfflineIndicator"; import { StagingBanner } from "@/components/StagingBanner"; import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability"; +import { captureSignupCompleted, captureTrialStarted, captureWelcomeCompleted } from "@/lib/activation-events"; +import { useSubscription } from "@/hooks/use-subscription"; +import { lazyChunk, loadChunk, namedChunk } from "@/lib/lazy-chunk"; import { appLayoutLoader, localizedRouteLoaders, preloadStartupRoute, routeLoaders, type RouteLoader } from "@/lib/route-modules"; import site from "@/config/site.json"; import type { PrerenderedRoute } from "@/lib/seo/pages"; @@ -41,7 +44,7 @@ function prerenderedPage(route: PrerenderedRoute) { } function prerenderedModule(loader: RouteLoader<{ default: ComponentType }>) { - const LazyPage = lazy(loader); + const LazyPage = lazyChunk(loader); return function PrerenderedPage() { const [Page] = useState(() => loader.loaded()?.default ?? LazyPage); return ; @@ -56,28 +59,28 @@ const AboutPage = prerenderedPage("/about"); const ContactPage = prerenderedPage("/contact"); // The Hungarian and Spanish addresses in src/config/localized-routes.json. const LOCALIZED_PAGES = LOCALIZED_PATHS.map((entry) => ({ ...entry, Page: prerenderedModule(localizedRouteLoaders[entry.path]) })); -const HomePage = lazy(routeLoaders["/app"]); -const CurrentDebatePage = lazy(routeLoaders["/app/debate"]); -const WorkspacesPage = lazy(routeLoaders["/app/workspaces"]); -const WorkspaceInvitePage = lazy(routeLoaders["/app/workspaces/invite"]); -const WorkspacePage = lazy(routeLoaders["/app/workspaces/:workspaceId"]); -const GeneratePage = lazy(routeLoaders["/app/generate"]); -const AttackPage = lazy(routeLoaders["/app/attack"]); -const BrainstormPage = lazy(routeLoaders["/app/brainstorm"]); -const FactoidsPage = lazy(routeLoaders["/app/factoids"]); -const AskPage = lazy(routeLoaders["/app/ask"]); -const CrossfirePage = lazy(routeLoaders["/app/crossfire"]); -const SettingsPage = lazy(routeLoaders["/app/settings"]); -const HistoryPage = lazy(routeLoaders["/app/history"]); -const LibraryPage = lazy(routeLoaders["/app/library"]); -const TimerPage = lazy(routeLoaders["/app/timer"]); -const SparringPage = lazy(routeLoaders["/app/sparring"]); -const AdminPage = lazy(routeLoaders["/app/admin"]); -const SharedPage = lazy(routeLoaders["/share/:code"]); -const FlowchartPage = lazy(routeLoaders["/app/flowchart"]); -const LoginPage = lazy(routeLoaders["/login"]); -const AuthCallbackPage = lazy(routeLoaders["/auth/callback"]); -const SourcesPage = lazy(routeLoaders["/expocit/sources"]); +const HomePage = lazyChunk(routeLoaders["/app"]); +const CurrentDebatePage = lazyChunk(routeLoaders["/app/debate"]); +const WorkspacesPage = lazyChunk(routeLoaders["/app/workspaces"]); +const WorkspaceInvitePage = lazyChunk(routeLoaders["/app/workspaces/invite"]); +const WorkspacePage = lazyChunk(routeLoaders["/app/workspaces/:workspaceId"]); +const GeneratePage = lazyChunk(routeLoaders["/app/generate"]); +const AttackPage = lazyChunk(routeLoaders["/app/attack"]); +const BrainstormPage = lazyChunk(routeLoaders["/app/brainstorm"]); +const FactoidsPage = lazyChunk(routeLoaders["/app/factoids"]); +const AskPage = lazyChunk(routeLoaders["/app/ask"]); +const CrossfirePage = lazyChunk(routeLoaders["/app/crossfire"]); +const SettingsPage = lazyChunk(routeLoaders["/app/settings"]); +const HistoryPage = lazyChunk(routeLoaders["/app/history"]); +const LibraryPage = lazyChunk(routeLoaders["/app/library"]); +const TimerPage = lazyChunk(routeLoaders["/app/timer"]); +const SparringPage = lazyChunk(routeLoaders["/app/sparring"]); +const AdminPage = lazyChunk(routeLoaders["/app/admin"]); +const SharedPage = lazyChunk(routeLoaders["/share/:code"]); +const FlowchartPage = lazyChunk(routeLoaders["/app/flowchart"]); +const LoginPage = lazyChunk(routeLoaders["/login"]); +const AuthCallbackPage = lazyChunk(routeLoaders["/auth/callback"]); +const SourcesPage = lazyChunk(routeLoaders["/expocit/sources"]); const NotFound = prerenderedPage("*"); const ToolsHubPage = prerenderedPage("/tools"); const DebateTimerToolPage = prerenderedPage("/tools/debate-timer"); @@ -87,7 +90,7 @@ const SpeechTimeCalculatorToolPage = prerenderedPage("/tools/speech-time-calcula // Public pages never load the signed-in header, navigation and debate dialogs. // Arriving from sign-in, the page being opened loads alongside the layout // instead of after it. -const AppLayout = lazy(() => { +const AppLayout = lazyChunk(() => { void preloadStartupRoute(window.location.pathname); return appLayoutLoader(); }); @@ -104,7 +107,7 @@ function loadOnboardingTour(): Promise { // leave the Start debate step closed for the rest of the page. The second // failure is the last: `noteTourChunkFailure` logs once and the tour owner // continues without it. - onboardingTourPromise ??= import("@/components/features/onboarding/OnboardingTour").then((mod) => { + onboardingTourPromise ??= loadChunk(() => import("@/components/features/onboarding/OnboardingTour")).then((mod) => { onboardingTourComponent = mod.OnboardingTour; return mod.OnboardingTour; }).catch((error: unknown) => { @@ -115,8 +118,7 @@ function loadOnboardingTour(): Promise { return onboardingTourPromise; } -const FirstRun = lazy(() => import("@/components/features/onboarding/FirstRun") - .then(({ FirstRun: Run }) => ({ default: Run }))); +const FirstRun = lazyChunk(() => namedChunk(() => import("@/components/features/onboarding/FirstRun"), "FirstRun")); // TanStack Query refetches after an `offline` then `online` pair, but a page // opened offline never sees `offline`. Retry whatever failed once the @@ -271,6 +273,22 @@ export function ToolRouteAnalytics(): null { return null; } +/** signup_completed and trial_started, once per new account. AppLayout's trial banner shares this subscription read. */ +export function ActivationAnalytics(): null { + const { user } = useAuth(); + const { tier, loading } = useSubscription(); + + useEffect(() => { + if (user) captureSignupCompleted(user); + }, [user]); + + useEffect(() => { + if (user && !loading) captureTrialStarted(user, tier); + }, [user, tier, loading]); + + return null; +} + function useRouteLabel(pathname: string): { fallback: RouteFallback; label: string } { const { t } = useTranslation(); const fallback = getRouteFallback(pathname); @@ -344,7 +362,10 @@ export function AppWelcome() { markIntroSeen(browserStorage())} + onIntroSeen={() => { + markIntroSeen(browserStorage()); + captureWelcomeCompleted(userId); + }} tour={(continueFromIntro) => } /> @@ -470,6 +491,7 @@ function AppRoutes() { element={ }> + }> diff --git a/src/components/WebTelemetry.tsx b/src/components/WebTelemetry.tsx index a2475da7e..60d0fd264 100644 --- a/src/components/WebTelemetry.tsx +++ b/src/components/WebTelemetry.tsx @@ -1,8 +1,9 @@ -import { lazy, Suspense, useEffect, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; +import { lazyChunk } from "@/lib/lazy-chunk"; import { loadOptionalComponent } from "@/components/optional-telemetry"; -const Analytics = lazy(() => loadOptionalComponent(() => import("@vercel/analytics/react"), "Analytics")); -const SpeedInsights = lazy(() => loadOptionalComponent(() => import("@vercel/speed-insights/react"), "SpeedInsights")); +const Analytics = lazyChunk(() => loadOptionalComponent(() => import("@vercel/analytics/react"), "Analytics")); +const SpeedInsights = lazyChunk(() => loadOptionalComponent(() => import("@vercel/speed-insights/react"), "SpeedInsights")); export function WebTelemetry() { const [ready, setReady] = useState(false); diff --git a/src/components/auth/SignInAgainButton.tsx b/src/components/auth/SignInAgainButton.tsx new file mode 100644 index 000000000..994f228dc --- /dev/null +++ b/src/components/auth/SignInAgainButton.tsx @@ -0,0 +1,28 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LogIn } from 'lucide-react'; +import { Button, type ButtonProps } from '@/components/ui/button'; +import { supabase } from '@/lib/supabase/client'; +import { endSessionToSignInAgain } from '@/lib/supabase/session-expiry'; + +/** The next step after the server refused this session: sign out and open the login page. */ +export function SignInAgainButton({ size = 'sm', variant = 'outline', className }: Pick) { + const { t } = useTranslation(); + const [pending, setPending] = useState(false); + return ( + + ); +} diff --git a/src/components/features/ask/AskChat.tsx b/src/components/features/ask/AskChat.tsx index cbb8db5b7..481a4cb61 100644 --- a/src/components/features/ask/AskChat.tsx +++ b/src/components/features/ask/AskChat.tsx @@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useLayoutEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Check, MessageCircle, Send, RotateCcw, Quote, Map, List, HelpCircle, Paperclip, X, Bookmark, Mic, MicOff, Search, Save, PanelLeftOpen, PlusCircle, Square, SlidersHorizontal, ArrowDown, AlertCircle } from 'lucide-react'; import { Button, ThinkingDots } from '@/components/ui/button'; +import { SignInAgainButton } from '@/components/auth/SignInAgainButton'; import { Owl, OwlAvatar } from '@/components/brand/Owl'; import { Input } from '@/components/ui/input'; import { Markdown } from '@/components/ui/markdown'; @@ -35,6 +36,8 @@ export interface AskChatError { title: string; description?: string; onRetry?: () => void; + /** The server refused the session: offer Sign in again instead of Retry. */ + signIn?: boolean; } interface AskChatProps { @@ -742,7 +745,7 @@ export function AskChat({ messages, streamingMessage, isLoading, error, onDismis {error.description &&

{error.description}

}
- {error.onRetry && ( + {error.signIn ? : error.onRetry && ( )} {onDismissError && ( diff --git a/src/components/features/sparring/SparringRound.tsx b/src/components/features/sparring/SparringRound.tsx index 089477ae1..2856a8ef9 100644 --- a/src/components/features/sparring/SparringRound.tsx +++ b/src/components/features/sparring/SparringRound.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { useOnlineStatus } from '@/hooks/use-online-status'; import { AlertCircle, Check, ChevronRight, CirclePause, CirclePlay, Clock3, CloudOff, RotateCcw, Send, UserRound, Wifi } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { SignInAgainButton } from '@/components/auth/SignInAgainButton'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; @@ -39,6 +40,8 @@ interface SparringRoundProps { /** The judge's scorecard as the model writes it. */ liveJudging?: LiveRunState; judgingError?: string | null; + /** The opponent or judge request was refused for auth: offer Sign in again instead of retrying. */ + signInRequired?: boolean; submissionError?: string | null; onSubmit: (content: string, delivery?: SpeechDelivery | null) => void; onRetryAi?: () => void; @@ -66,6 +69,7 @@ export function SparringRound({ judging = false, liveJudging, judgingError, + signInRequired = false, submissionError, onSubmit, onRetryAi, @@ -238,7 +242,9 @@ export function SparringRound({

{t('sparring.round.scoreFailed', 'Could not score this round')}

{judgingError}

- + {signInRequired + ? + : } ) : ( <> @@ -318,7 +324,9 @@ export function SparringRound({

{t('sparring.round.opponentFailed', 'The opponent could not answer')}

{aiError}

- + {signInRequired + ? + : } ) : ( <> diff --git a/src/components/features/tools/tool-run.tsx b/src/components/features/tools/tool-run.tsx index 362937cef..80b68adbc 100644 --- a/src/components/features/tools/tool-run.tsx +++ b/src/components/features/tools/tool-run.tsx @@ -4,10 +4,21 @@ import { useTranslation } from 'react-i18next'; import { AlertCircle, X } from 'lucide-react'; import { Button, ThinkingDots } from '@/components/ui/button'; import type { LiveGeneration, LivePhase } from '@/lib/ai/client'; +import { getUserFacingAIError } from '@/lib/ai/error-display'; +import { aiFailureRecovery, type AIFailureRecovery } from '@/lib/ai/failure-reason'; +import { SignInAgainButton } from '@/components/auth/SignInAgainButton'; export interface ToolRunError { title: string; description?: string; + /** The next step. Retry shows unless the failure would repeat (`sign_in`, `none`). */ + recovery?: AIFailureRecovery; +} + +/** A failed run's panel content: the page's title, the failure's plain reason, and its next step. */ +// eslint-disable-next-line react-refresh/only-export-components +export function toolRunError(title: string, error: unknown, description: string = getUserFacingAIError(error)): ToolRunError { + return { title, description, recovery: aiFailureRecovery(error) }; } // The page can unmount mid-run (results still land in the store), so the request @@ -134,7 +145,9 @@ export function ToolRunErrorPanel({ error, onRetry, onDismiss }: { error: ToolRu {error.description &&

{error.description}

}
- {onRetry && } + {error.recovery === 'sign_in' + ? + : onRetry && error.recovery !== 'none' && } {onDismiss && (
- + {signInRequired ? : ( + + )} )} @@ -1064,10 +1074,12 @@ export default function CrossfirePage() { ? t('crossfire.timeoutDesc') : t(state.request.message, { defaultValue: state.request.message })}

- + {signInRequired ? : ( + + )} )} diff --git a/src/pages/FactoidsPage.tsx b/src/pages/FactoidsPage.tsx index 2d7621601..684c3f036 100644 --- a/src/pages/FactoidsPage.tsx +++ b/src/pages/FactoidsPage.tsx @@ -9,13 +9,12 @@ import { FactoidsForm } from '@/components/features/factoids/FactoidsForm'; import { FactoidsResults } from '@/components/features/factoids/FactoidsResults'; import { factoids, type DiscoveredSource } from '@/lib/ai/client'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; -import { getUserFacingAIError } from '@/lib/ai/error-display'; import { DailyUsage, SubscriptionErrorModals } from '@/components/features/subscription'; import type { FactoidsInput } from '@/lib/ai/schemas/factoids'; import { useToast } from '@/hooks/use-toast'; import { EmptyState } from '@/components/ui/empty-state'; import { PageHeader } from '@/components/layout/PageHeader'; -import { ToolRunErrorPanel, isCanceled, useRevealResults, useToolRun } from '@/components/features/tools/tool-run'; +import { ToolRunErrorPanel, isCanceled, toolRunError, useRevealResults, useToolRun } from '@/components/features/tools/tool-run'; import { LiveToolRun } from '@/components/features/tools/live-draft'; import { factoidsDraftSections } from '@/components/features/tools/live-sections'; import { factoidsDiscussPrompt, openDiscussion } from '@/components/features/tools/discuss'; @@ -52,7 +51,7 @@ export default function FactoidsPage() { }, [drafting, revealResults]); const lastInput = useRef(null); const navigate = useNavigate(); - const subError = useSubscriptionError(); + const subError = useSubscriptionError('factoids'); const { t } = useTranslation(); const [discoveredSources, setDiscoveredSources] = useState([]); const resultSaved = useResultSaved(factoidsResult); @@ -80,7 +79,7 @@ export default function FactoidsPage() { if (isCanceled(error, signal)) return; console.error('Failed to find factoids:', error); if (!subError.handleSubscriptionError(error)) { - run.setError({ title: t('factoids.failed'), description: getUserFacingAIError(error) }); + run.setError(toolRunError(t('factoids.failed'), error)); } } finally { setFactoidsLoading(false); diff --git a/src/pages/GeneratePage.tsx b/src/pages/GeneratePage.tsx index 4fec501e4..48d22f3e1 100644 --- a/src/pages/GeneratePage.tsx +++ b/src/pages/GeneratePage.tsx @@ -24,7 +24,7 @@ import { Check, Save, Target } from 'lucide-react'; import { Owl } from '@/components/brand/Owl'; import { EmptyState } from '@/components/ui/empty-state'; import { PageHeader } from '@/components/layout/PageHeader'; -import { ToolRunErrorPanel, isCanceled, useRevealResults, useToolRun } from '@/components/features/tools/tool-run'; +import { ToolRunErrorPanel, isCanceled, toolRunError, useRevealResults, useToolRun } from '@/components/features/tools/tool-run'; import { LiveToolRun } from '@/components/features/tools/live-draft'; import { generateDraftSections } from '@/components/features/tools/live-sections'; import { generateDiscussPrompt, openDiscussion } from '@/components/features/tools/discuss'; @@ -66,7 +66,7 @@ export default function GeneratePage() { const config = getFeatureConfig('generate'); const { toast } = useToast(); const navigate = useNavigate(); - const subError = useSubscriptionError(); + const subError = useSubscriptionError('generate'); const { t } = useTranslation(); const [saveDialogOpen, setSaveDialogOpen] = useState(false); const run = useToolRun('generate'); @@ -137,13 +137,13 @@ export default function GeneratePage() { if (subError.handleSubscriptionError(error)) return; if (isAIAuthError(error)) { - run.setError({ title: t('generate.authRequired'), description: getUserFacingAIError(error) }); + run.setError(toolRunError(t('generate.authRequired'), error)); } else if (isAIRateLimitError(error)) { - run.setError({ title: t('rateLimit.title'), description: getUserFacingAIError(error) }); + run.setError(toolRunError(t('rateLimit.title'), error)); } else if (isTimeoutError(error)) { - run.setError({ title: t('generate.timeout'), description: t('generate.timeoutDesc') }); + run.setError(toolRunError(t('generate.timeout'), error, t('generate.timeoutDesc'))); } else { - run.setError({ title: t('generate.failed'), description: getUserFacingAIError(error) }); + run.setError(toolRunError(t('generate.failed'), error)); } } finally { setGenerateLoading(false); diff --git a/src/pages/SparringPage.tsx b/src/pages/SparringPage.tsx index fe1bcae52..6a6db05b2 100644 --- a/src/pages/SparringPage.tsx +++ b/src/pages/SparringPage.tsx @@ -7,6 +7,7 @@ import { useSparringStore } from '@/lib/stores/sparring-store'; import { useSettingsStore } from '@/lib/stores/settings-store'; import { AIError, generateSparringTurn, isAISubscriptionError, isInvalidOutputError, judgeSparringSession } from '@/lib/ai/client'; import { getUserFacingAIError } from '@/lib/ai/error-display'; +import { aiFailureRecovery } from '@/lib/ai/failure-reason'; import type { SparringRespondInput, SparringScoreInput, SparringSpeech } from '@/lib/ai/schemas/sparring'; import { SparringSetup, type SparringSetupValues } from '@/components/features/sparring/SparringSetup'; import { SparringRound } from '@/components/features/sparring/SparringRound'; @@ -48,7 +49,7 @@ export default function SparringPage() { const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); - const subError = useSubscriptionError(); + const subError = useSubscriptionError('sparring'); const { handleSubscriptionError } = subError; const workspaceLaunch = (location.state as { workspaceLaunch?: unknown } | null)?.workspaceLaunch === true; const workspaceArtifact = (location.state as { workspaceArtifact?: unknown } | null)?.workspaceArtifact; @@ -76,6 +77,8 @@ export default function SparringPage() { const [liveJudging, setLiveJudging] = useState({}); const [aiError, setAiError] = useState(null); const [judgingError, setJudgingError] = useState(null); + // The shown opponent or judge error was a refused session: Sign in again replaces Retry. + const [signInRequired, setSignInRequired] = useState(false); const [submissionError, setSubmissionError] = useState(null); const isOnline = useOnlineStatus(); const [retryNonce, setRetryNonce] = useState(0); @@ -190,6 +193,7 @@ export default function SparringPage() { aiInFlightRef.current = true; setAiLoading(true); setAiError(null); + setSignInRequired(false); const { speaker: _speaker, ...turnInput } = currentTurn; const input: SparringRespondInput = { action: 'respond', @@ -236,6 +240,7 @@ export default function SparringPage() { .catch((error: unknown) => { if (aiRequestRef.current !== requestKey) return; handleSubscriptionError(error); + setSignInRequired(aiFailureRecovery(error) === 'sign_in'); setAiError(readableError(error, t('sparring.round.aiRequestFailed', 'The AI request failed. Check your connection and try again.'))); }) .finally(() => { @@ -258,6 +263,7 @@ export default function SparringPage() { judgeRequestRef.current = requestKey; judgeInFlightRef.current = true; setJudgingError(null); + setSignInRequired(false); const input: SparringScoreInput = { action: 'score', sessionId: session.id, @@ -287,6 +293,7 @@ export default function SparringPage() { .catch((error: unknown) => { if (judgeRequestRef.current !== requestKey) return; handleSubscriptionError(error); + setSignInRequired(aiFailureRecovery(error) === 'sign_in'); setJudgingError(isInvalidOutputError(error) ? `${t('sparring.round.scorecardMalformed', 'The scorecard came back malformed. Try scoring again.')}${error instanceof AIError && error.notCharged ? ` ${t('aiErrors.notCharged')}` : ''}` : readableError(error, t('sparring.round.judgeRequestFailed', 'The judge request failed. Check your connection and try again.'))); @@ -452,6 +459,7 @@ export default function SparringPage() { aiError={aiError} judging={session.phase === 'judging'} judgingError={judgingError} + signInRequired={signInRequired && Boolean(aiError || judgingError)} submissionError={submissionError} onSubmit={handleSubmit} onRetryAi={retryAi} diff --git a/src/test/lib/activation-events.test.ts b/src/test/lib/activation-events.test.ts new file mode 100644 index 000000000..5fcfb7c4a --- /dev/null +++ b/src/test/lib/activation-events.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { captureProductEventMock, activeUser } = vi.hoisted(() => ({ + captureProductEventMock: vi.fn(), + activeUser: { id: null as string | null }, +})); + +vi.mock('@/lib/observability', () => ({ captureProductEvent: captureProductEventMock })); +vi.mock('@/lib/supabase/client', () => ({ getActiveUserId: () => activeUser.id })); + +import { + captureFirstAISuccess, + captureOncePerAccount, + captureSignupCompleted, + captureTrialStarted, + captureWelcomeCompleted, +} from '@/lib/activation-events'; + +const NOW = Date.parse('2026-10-28T12:00:00Z'); +const newUser = (id: string, appMetadata: Record) => ({ id, created_at: '2026-10-28T11:50:00Z', app_metadata: appMetadata }); + +function sent(name: string) { + return captureProductEventMock.mock.calls.filter(([event]) => event === name); +} + +describe('activation events', () => { + beforeEach(() => { + localStorage.clear(); + captureProductEventMock.mockReset(); + activeUser.id = null; + }); + + it('sends first_ai_success once per account, and again for another account', () => { + activeUser.id = 'user-a'; + captureFirstAISuccess('ask'); + captureFirstAISuccess('generate'); + activeUser.id = 'user-b'; + captureFirstAISuccess('crossfire'); + + expect(sent('first_ai_success')).toEqual([ + ['first_ai_success', { tool: 'ask' }], + ['first_ai_success', { tool: 'crossfire' }], + ]); + }); + + it('sends nothing without an account or when storage cannot remember', () => { + captureFirstAISuccess('ask'); + const blocked = { getItem: () => { throw new Error('blocked'); }, setItem: () => undefined }; + expect(captureOncePerAccount('welcome_completed', 'user-a', undefined, blocked)).toBe(false); + expect(captureOncePerAccount('welcome_completed', 'user-a', undefined, null)).toBe(false); + expect(captureProductEventMock).not.toHaveBeenCalled(); + }); + + it('counts a new account once, with how it signed up, and not a returning one', () => { + expect(captureSignupCompleted(newUser('google-user', { provider: 'google' }), NOW)).toBe(true); + expect(captureSignupCompleted(newUser('google-user', { provider: 'google' }), NOW)).toBe(false); + expect(captureSignupCompleted(newUser('demo-user', { provider: 'email', demo_access_code_id: 'code-1' }), NOW)).toBe(true); + expect(captureSignupCompleted({ id: 'old-user', created_at: '2026-09-01T00:00:00Z', app_metadata: { provider: 'email' } }, NOW)).toBe(false); + + expect(sent('signup_completed')).toEqual([ + ['signup_completed', { method: 'google' }], + ['signup_completed', { method: 'demo' }], + ]); + }); + + it('starts the trial once for a new account on the trial plan only', () => { + expect(captureTrialStarted(newUser('trial-user', { provider: 'email' }), 'expired', NOW)).toBe(false); + expect(captureTrialStarted(newUser('trial-user', { provider: 'email' }), 'trial', NOW)).toBe(true); + expect(captureTrialStarted(newUser('trial-user', { provider: 'email' }), 'trial', NOW)).toBe(false); + expect(captureTrialStarted({ id: 'old-user', created_at: '2026-01-01T00:00:00Z' }, 'trial', NOW)).toBe(false); + expect(sent('trial_started')).toHaveLength(1); + }); + + it('sends welcome_completed once per account', () => { + captureWelcomeCompleted('user-a'); + captureWelcomeCompleted('user-a'); + captureWelcomeCompleted(undefined); + expect(sent('welcome_completed')).toHaveLength(1); + }); +}); diff --git a/src/test/lib/lazy-chunk.test.ts b/src/test/lib/lazy-chunk.test.ts new file mode 100644 index 000000000..14842a15b --- /dev/null +++ b/src/test/lib/lazy-chunk.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { isChunkLoadError, loadChunk } from '@/lib/lazy-chunk'; +import { resetStaleChunkReloadForTests } from '@/lib/reload-stale-chunk'; + +const PENDING = Symbol('pending'); + +/** What the promise has done after the microtask queue drains: its value, its error, or nothing yet. */ +async function settled(promise: Promise): Promise { + const result = await Promise.race([ + promise.then((value) => value, (error: unknown) => ({ rejected: error })), + new Promise((resolve) => setTimeout(() => resolve(PENDING), 20)), + ]); + return result; +} + +const missingChunk = () => new TypeError('Failed to fetch dynamically imported module: https://debateowl.com/assets/LoginPage-old.js'); + +describe('loadChunk', () => { + beforeEach(() => resetStaleChunkReloadForTests()); + + it('returns a module that loaded', async () => { + const module = { default: () => null }; + await expect(loadChunk(() => Promise.resolve(module), () => false)).resolves.toBe(module); + }); + + it('waits for the reload instead of handing back nothing after a stale-chunk preload error', async () => { + // Vite resolves the import to undefined once the preload listener prevented its error. + const reload = vi.fn(() => true); + const consumer = loadChunk(() => Promise.resolve(undefined), reload).then(({ default: Page }: { default: unknown }) => Page); + expect(await settled(consumer)).toBe(PENDING); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('fails with a named error when an empty module cannot be reloaded', async () => { + const result = await settled(loadChunk(() => Promise.resolve(undefined), () => false)); + expect(result).toEqual({ rejected: expect.objectContaining({ name: 'ChunkLoadError' }) }); + }); + + it('reloads once for a missing chunk and lets the error through when that is used up', async () => { + expect(await settled(loadChunk(() => Promise.reject(missingChunk()), () => true))).toBe(PENDING); + const second = await settled(loadChunk(() => Promise.reject(missingChunk()), () => false)); + expect(second).toEqual({ rejected: expect.objectContaining({ name: 'TypeError' }) }); + }); + + it('does not reload for an error that is not a missing chunk', async () => { + const reload = vi.fn(() => true); + const result = await settled(loadChunk(() => Promise.reject(new RangeError('bug in the page')), reload)); + expect(result).toEqual({ rejected: expect.objectContaining({ name: 'RangeError' }) }); + expect(reload).not.toHaveBeenCalled(); + }); + + it('recognizes each browser’s missing-module message', () => { + expect(isChunkLoadError(missingChunk())).toBe(true); + expect(isChunkLoadError(new TypeError('error loading dynamically imported module: /assets/a.js'))).toBe(true); + expect(isChunkLoadError(new TypeError('Importing a module script failed.'))).toBe(true); + expect(isChunkLoadError(new TypeError('Failed to fetch'))).toBe(false); + }); +}); diff --git a/src/test/lib/reload-stale-chunk.test.ts b/src/test/lib/reload-stale-chunk.test.ts index 54516c0ac..4bd9b2c21 100644 --- a/src/test/lib/reload-stale-chunk.test.ts +++ b/src/test/lib/reload-stale-chunk.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it, vi } from 'vitest'; -import { installStaleChunkReload } from '@/lib/reload-stale-chunk'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { installStaleChunkReload, resetStaleChunkReloadForTests } from '@/lib/reload-stale-chunk'; function fakeWindow() { const listeners = new Map void>(); @@ -24,6 +24,8 @@ function fakeWindow() { } describe('stale chunk reload', () => { + beforeEach(() => resetStaleChunkReloadForTests()); + it('reloads once when a route chunk is missing and then lets the error stand', () => { const { target, listeners, reload } = fakeWindow(); installStaleChunkReload(target as unknown as Window); @@ -35,6 +37,8 @@ describe('stale chunk reload', () => { expect(first.defaultPrevented).toBe(true); expect(reload).toHaveBeenCalledTimes(1); + // The reloaded document starts with no reload pending; sessionStorage remembers the first. + resetStaleChunkReloadForTests(); const second = new Event('vite:preloadError', { cancelable: true }); handler!(second); expect(second.defaultPrevented).toBe(false); From 4ea85d5402b057be1810af54d540faef48370ee5 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 20:31:40 +0000 Subject: [PATCH 2/5] Add an ai-failures recipe for Sign in again, Retry and the plan dialog Co-Authored-By: Claude Opus 5.5 --- .../verify-debatelab/features/README.md | 1 + .../verify-debatelab/features/ai-failures.md | 27 ++++ .../recipes/ai-failures.spec.ts | 117 ++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 .claude/skills/verify-debatelab/features/ai-failures.md create mode 100644 .claude/skills/verify-debatelab/recipes/ai-failures.spec.ts diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 3bcb4d850..5532421af 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -51,6 +51,7 @@ Each feature file starts with an H1 title and one paragraph describing the user- - [Workspace autosave](./workspace-autosave.md): a slow Crossfire or Sparring save from a Workspace never overwrites newer text (`recipes/workspace-autosave.spec.ts`). Needs `up --collab`; skips without it. - [Account data](./account-data.md): Download my data and Delete account, each proven against a bystander (`recipes/account-data.spec.ts`); Delete all my data scoped against a bystander (`e2e/full-stack/account-data.spec.ts`). - Account: output language reaching the model from a new device, flowchart node delete (at once, with an `Undo` toast; no confirm dialog), save and load, and the speech timer (`R` resets only while paused) (`recipes/account.spec.ts`). +- [AI failures](./ai-failures.md): the reason and next step for a failed AI request: Sign in again after a refused session in Generate, Ask, Sparring and Crossfire, Retry after a network failure in Ask, and the plan dialog for a limit (`recipes/ai-failures.spec.ts`). - [Usage limits](./usage-limits.md): Opus refused when its weight does not fit today while Haiku still runs, and the weekly AI budget dialog (`recipes/usage-limits.spec.ts`). - Billing: expired account blocked, promo code restores access, and a discount code claimed once (a second Apply does not count again) stays in the pricing dialog and is sent with the upgrade (`recipes/billing.spec.ts`); each billing state (trial, extended access, active Pro, Pro set to cancel, promo Pro without Stripe, payment failed, subscription ended) shown the same way by Settings, the pricing dialog (its one plan button and Current badge) and a refused AI request, in English and Hungarian, light and dark, at 360 and 375 px (mobile project) and 768 and 1280 px (desktop project); the usage line on every tool page matching Settings and updating after a run; the trial banner; and `/app/billing` landing on the Settings subscription card (`recipes/billing-states.spec.ts`). The pricing dialog's Manage path stubs `create-portal` with its own no-subscription answer, since the portal needs Stripe; signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`). - [Localization](./localization.md): tab titles per route and language, translated form validation and server refusals, the language switcher on sign-in and 404, and `?lang=` with the `/es` and `/hu` pages (`recipes/localization.spec.ts`). diff --git a/.claude/skills/verify-debatelab/features/ai-failures.md b/.claude/skills/verify-debatelab/features/ai-failures.md new file mode 100644 index 000000000..c0f1f8a7d --- /dev/null +++ b/.claude/skills/verify-debatelab/features/ai-failures.md @@ -0,0 +1,27 @@ +# AI failures: the reason and the next step + +When an AI request fails, the tool says why in one plain sentence and offers the step that can work. A daily, weekly or plan limit opens the same plan dialog every tool uses, and a Pro-only model opens the upgrade dialog. When the server refuses the session (401, or a 403 that is not about the model), the error offers `Sign in again` instead of Retry, because sending again would be refused the same way. It signs the tab out and opens `/login` with `Your session expired. Sign in again.`, and signing in returns to the page. Timeouts, network failures, provider errors, malformed answers and server errors keep Retry. Crossfire and Sparring retries reuse the round's quota session, so a retry is not charged again. A request that is too large has no Retry. The mapping is `aiFailureRecovery` in `src/lib/ai/failure-reason.ts`, and the same file gives `ai_request_failed` its `error_code` and `stage`. + +## Sub-features + +- `fail-signin-tool`: Generate answered 401 shows `Sign-in required` with `Sign in again` and no Retry; the button lands on `/login` with the expired-session notice. +- `fail-network-ask`: an Ask question whose request never reached the server shows `Check your connection and try again.` with Retry; once the network is back, Retry answers it and the question appears once. +- `fail-signin-ask`: Ask answered 401 shows `Sign in again` in the chat, with no Retry. +- `fail-limit-tool`: Brainstorm with `daily_limit_override: 0` opens `Daily AI uses used up`. +- `fail-signin-sparring`: the opponent's turn answered 401 shows `The opponent could not answer` with `Sign in again` instead of `Retry opponent turn`. +- `fail-signin-crossfire`: the AI's first question in an AI-led round answered 401 shows `Sign in again` instead of `retry-ai`. + +## How to get to it (user POV) + +- Any AI tool when its request fails: `/app/generate`, `/app/attack`, `/app/brainstorm`, `/app/factoids`, `/app/ask`, `/app/sparring`, `/app/crossfire`. + +## Driving it with verify.sh + Playwright + +- `bin/verify.sh drive recipes/ai-failures` runs each sub-feature in its own test, on both viewports. +- The recipe forces each failure in the browser with `page.route`: a 401 JSON answer for `functions/v1/ai` (letting a round's `quotaCheck` through) or `functions/v1/ai-stream`, or `route.abort("internetdisconnected")` for a network failure. The limit step sets `daily_limit_override: 0` through `admin`. +- Provider failures with Retry are `gen-error` in `recipes/case-generation.spec.ts`; the Ask limit with Retry is `usage-ask-not-sent` in `recipes/usage-limits.spec.ts`. + +## Gotchas + +- A real 401 from a function makes the client refresh the session once (`verifySessionAfterRejection`). On this stack the refresh succeeds, so the tab stays signed in until `Sign in again` is clicked. +- A timeout needs the 200-second client budget to pass, so no recipe forces one. `src/lib/ai/failure-reason.test.ts` covers its mapping. diff --git a/.claude/skills/verify-debatelab/recipes/ai-failures.spec.ts b/.claude/skills/verify-debatelab/recipes/ai-failures.spec.ts new file mode 100644 index 000000000..fd36ef690 --- /dev/null +++ b/.claude/skills/verify-debatelab/recipes/ai-failures.spec.ts @@ -0,0 +1,117 @@ +import type { Page, Route } from "@playwright/test"; +import { admin, AI_TIMEOUT, chooseForMotion, expect, startDebate, test } from "./fixtures"; + +// Feature map: features/ai-failures.md. Each failure is forced in the browser, +// so the provider is never called and the recipe runs the same in both modes. +const motion = "This House would make public transport free at the point of use"; +const resolution = "Resolved: cities should ban private cars downtown."; + +/** Answers every `ai` request except a round's quota check as the server does for a refused session. */ +async function refuseSession(page: Page): Promise { + await page.route("**/functions/v1/ai", async (route: Route) => { + if (route.request().method() === "POST" && (route.request().postDataJSON() as { quotaCheck?: boolean } | null)?.quotaCheck) { + await route.fallback(); + return; + } + await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: "Invalid JWT" }) }); + }); +} + +async function expectBackOnLogin(page: Page): Promise { + await expect(page).toHaveURL(/\/login$/, { timeout: AI_TIMEOUT }); + await expect(page.getByRole("status").filter({ hasText: "Your session expired. Sign in again." })).toBeVisible(); +} + +test("fail-signin-tool: a refused session on Generate offers Sign in again, not Retry, and it opens the login page", async ({ signedIn: page, evidence }) => { + await startDebate(page, motion); + await page.goto("/app/generate"); + await refuseSession(page); + await page.getByRole("button", { name: "Generate debate case" }).click(); + const failure = page.getByRole("alert").filter({ hasText: "Sign-in required" }); + await expect(failure).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(failure.getByRole("button", { name: "Retry" })).toHaveCount(0); + await evidence.shot("01-generate-sign-in-again"); + await evidence.aria("01-generate-sign-in-again", failure); + + await failure.getByRole("button", { name: "Sign in again" }).click(); + await expectBackOnLogin(page); + await evidence.shot("02-login-after-sign-in-again"); +}); + +test("fail-network-ask: a question that never reached the server stays with Retry, and Retry answers it once", async ({ signedIn: page, evidence }) => { + const question = "What is the strongest objection to free public transport?"; + await page.goto("/app/ask"); + await page.route("**/functions/v1/ai-stream", (route) => route.abort("internetdisconnected")); + await page.route("**/functions/v1/ai", (route) => route.abort("internetdisconnected")); + await page.getByRole("textbox", { name: "Message to your debate coach" }).fill(question); + await page.keyboard.press("Enter"); + const failure = page.getByRole("alert").filter({ hasText: "Check your connection and try again." }); + await expect(failure).toBeVisible({ timeout: AI_TIMEOUT }); + await evidence.shot("01-ask-network-retry"); + await evidence.aria("01-ask-network-retry", failure); + + await page.unroute("**/functions/v1/ai-stream"); + await page.unroute("**/functions/v1/ai"); + const answer = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: AI_TIMEOUT }); + await failure.getByRole("button", { name: "Retry" }).click(); + await answer; + await expect(failure).toHaveCount(0); + await expect(page.getByText(question)).toHaveCount(1); + await evidence.shot("02-ask-answered-after-retry"); +}); + +test("fail-signin-ask: a refused session in Ask offers Sign in again", async ({ signedIn: page, evidence }) => { + await page.goto("/app/ask"); + await page.route("**/functions/v1/ai-stream", (route) => route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: "Invalid JWT" }) })); + await refuseSession(page); + await page.getByRole("textbox", { name: "Message to your debate coach" }).fill("How do I weigh two impacts?"); + await page.keyboard.press("Enter"); + const failure = page.getByRole("alert").filter({ has: page.getByRole("button", { name: "Sign in again" }) }); + await expect(failure).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(failure.getByRole("button", { name: "Retry" })).toHaveCount(0); + await evidence.shot("01-ask-sign-in-again"); + await evidence.aria("01-ask-sign-in-again", failure); +}); + +test("fail-limit-tool: a tool refused by the daily limit opens the plan dialog", async ({ signedIn: page, user, evidence }) => { + await admin.from("user_settings").update({ daily_limit_override: 0 }).eq("user_id", await user.userId()); + await page.goto("/app/brainstorm"); + await page.getByLabel("Motion / topic *").fill(motion); + await page.getByRole("button", { name: "Start brainstorming" }).click(); + await chooseForMotion(page, "once"); + const dialog = page.getByRole("dialog").filter({ hasText: "Daily AI uses used up" }); + await expect(dialog).toBeVisible({ timeout: AI_TIMEOUT }); + await evidence.shot("01-brainstorm-limit-dialog"); + await evidence.aria("01-brainstorm-limit-dialog", dialog); +}); + +test("fail-signin-sparring: a refused session for the opponent's turn offers Sign in again instead of Retry", async ({ signedIn: page, evidence }) => { + await page.goto("/app/sparring"); + await page.getByLabel("Motion").fill(motion); + await page.getByRole("combobox", { name: "Round format" }).click(); + await page.getByRole("option", { name: /Lincoln-Douglas · 5 speeches/ }).click(); + await page.getByRole("button", { name: "Start round" }).click(); + await chooseForMotion(page, "create"); + const draft = page.getByRole("textbox", { name: "Draft for Affirmative constructive" }); + await expect(draft).toBeVisible({ timeout: AI_TIMEOUT }); + await refuseSession(page); + await draft.fill("Universal access removes the direct price barrier to participation in work, school, and civic life."); + await page.getByRole("button", { name: "Submit speech" }).click(); + await expect(page.getByRole("heading", { name: "The opponent could not answer" })).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(page.getByRole("button", { name: "Retry opponent turn" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Sign in again" })).toBeVisible(); + await evidence.shot("01-sparring-sign-in-again"); +}); + +test("fail-signin-crossfire: a refused session for the AI's question offers Sign in again instead of Retry", async ({ signedIn: page, evidence }) => { + await page.goto("/app/crossfire"); + await page.getByTestId("crossfire-resolution").fill(resolution); + await page.getByTestId("crossfire-argument").fill("Private cars create congestion and unsafe streets."); + await page.getByTestId("mode-ai-questions-user").click(); + await refuseSession(page); + await page.getByTestId("start-crossfire").click(); + await chooseForMotion(page, "create"); + await expect(page.getByRole("button", { name: "Sign in again" })).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(page.getByTestId("retry-ai")).toHaveCount(0); + await evidence.shot("01-crossfire-sign-in-again"); +}); From b8f18da14a28f28e0ccd3a96de1b6fdf33fead5f Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 20:33:52 +0000 Subject: [PATCH 3/5] Give the new tests' stub functions return types Co-Authored-By: Claude Opus 5.5 --- src/lib/ai/failure-reason.test.ts | 2 +- src/test/lib/activation-events.test.ts | 2 +- src/test/lib/lazy-chunk.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/ai/failure-reason.test.ts b/src/lib/ai/failure-reason.test.ts index ed418f7a3..eca661fea 100644 --- a/src/lib/ai/failure-reason.test.ts +++ b/src/lib/ai/failure-reason.test.ts @@ -13,7 +13,7 @@ vi.mock('@/lib/observability', () => ({ vi.mock('@/lib/supabase/client', () => ({ getActiveAccessToken: () => 'token', - getActiveUserId: () => null, + getActiveUserId: (): null => null, invokeAuthenticatedFunction: invokeMock, invokeAuthenticatedFunctionWith: invokeMock, supabase: { auth: { getSession: vi.fn() } }, diff --git a/src/test/lib/activation-events.test.ts b/src/test/lib/activation-events.test.ts index 5fcfb7c4a..b7f3d0e4b 100644 --- a/src/test/lib/activation-events.test.ts +++ b/src/test/lib/activation-events.test.ts @@ -45,7 +45,7 @@ describe('activation events', () => { it('sends nothing without an account or when storage cannot remember', () => { captureFirstAISuccess('ask'); - const blocked = { getItem: () => { throw new Error('blocked'); }, setItem: () => undefined }; + const blocked = { getItem: () => { throw new Error('blocked'); }, setItem: (): void => undefined }; expect(captureOncePerAccount('welcome_completed', 'user-a', undefined, blocked)).toBe(false); expect(captureOncePerAccount('welcome_completed', 'user-a', undefined, null)).toBe(false); expect(captureProductEventMock).not.toHaveBeenCalled(); diff --git a/src/test/lib/lazy-chunk.test.ts b/src/test/lib/lazy-chunk.test.ts index 14842a15b..61af1ff7f 100644 --- a/src/test/lib/lazy-chunk.test.ts +++ b/src/test/lib/lazy-chunk.test.ts @@ -19,7 +19,7 @@ describe('loadChunk', () => { beforeEach(() => resetStaleChunkReloadForTests()); it('returns a module that loaded', async () => { - const module = { default: () => null }; + const module = { default: (): null => null }; await expect(loadChunk(() => Promise.resolve(module), () => false)).resolves.toBe(module); }); From 180f3da681d5b6b8d9f2c4dbcf93674941a05487 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 20:43:11 +0000 Subject: [PATCH 4/5] Send no status for a client timeout and name a missing lazy export A TimeoutError's 408 is the browser's own deadline, so http_status left it reading as a server answer. namedChunk now throws ChunkLoadError for an export that is not there instead of handing React undefined. Co-Authored-By: Claude Opus 5.5 --- src/lib/ai/failure-reason.test.ts | 7 ++++++- src/lib/ai/failure-reason.ts | 6 +++++- src/lib/lazy-chunk.ts | 10 +++++++--- src/test/lib/lazy-chunk.test.ts | 6 +++++- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/lib/ai/failure-reason.test.ts b/src/lib/ai/failure-reason.test.ts index eca661fea..9875fa9df 100644 --- a/src/lib/ai/failure-reason.test.ts +++ b/src/lib/ai/failure-reason.test.ts @@ -32,7 +32,7 @@ import { AIUnknownError, TimeoutError, } from '@/lib/ai/errors'; -import { aiFailureCode, aiFailureRecovery, aiFailureStage } from '@/lib/ai/failure-reason'; +import { aiFailureCode, aiFailureHttpStatus, aiFailureRecovery, aiFailureStage } from '@/lib/ai/failure-reason'; import { callSupabaseAI } from '@/lib/ai/request'; const config = { provider: 'openrouter' as const, model: 'openai/gpt-6-luna', reasoningEffort: 'low' as const }; @@ -59,6 +59,11 @@ describe('AI failure reasons', () => { expect(aiFailureRecovery(error)).toBe(recovery); }); + it('sends no status for the browser\'s own timeout', () => { + expect(aiFailureHttpStatus(new TimeoutError())).toBeUndefined(); + expect(aiFailureHttpStatus(new AIServerError('boom', { statusCode: 503 }))).toBe(503); + }); + it('reports a caller abort as aborted whatever the error says', () => { expect(aiFailureCode(new TimeoutError(), true)).toBe('aborted'); }); diff --git a/src/lib/ai/failure-reason.ts b/src/lib/ai/failure-reason.ts index a75489616..4c0793b66 100644 --- a/src/lib/ai/failure-reason.ts +++ b/src/lib/ai/failure-reason.ts @@ -64,8 +64,12 @@ export function aiFailureStage(error: unknown, stage: AIFailureStage = 'request' return isResponseError(error) ? 'parse' : stage; } -/** The HTTP status that goes with a failure, when one is known and plausible. */ +/** + * The HTTP status that goes with a failure, when one is known and plausible. + * A TimeoutError's 408 is the browser's own deadline, not a server answer. + */ export function aiFailureHttpStatus(error: unknown): number | undefined { + if (isTimeoutError(error)) return undefined; const status = error instanceof AIError ? error.statusCode : undefined; return Number.isInteger(status) && status! >= 100 && status! <= 599 ? status : undefined; } diff --git a/src/lib/lazy-chunk.ts b/src/lib/lazy-chunk.ts index 6a6b32627..3208e116d 100644 --- a/src/lib/lazy-chunk.ts +++ b/src/lib/lazy-chunk.ts @@ -17,8 +17,8 @@ export function isChunkLoadError(error: unknown): boolean { /** A chunk that loaded as nothing and could not be reloaded. Named for the route error boundary and Sentry. */ class ChunkLoadError extends Error { - constructor() { - super('A page file did not load.'); + constructor(message = 'A page file did not load.') { + super(message); this.name = 'ChunkLoadError'; } } @@ -48,7 +48,11 @@ export function loadChunk(load: () => Promise, reload: /** One named export of a code-split module, as a lazy component's `{ default }`. */ export function namedChunk(load: () => Promise, name: K): Promise<{ default: M[K] }> { - return loadChunk(load).then((module) => ({ default: module[name] })); + return loadChunk(load).then((module) => { + // A renamed export would otherwise reach React as an invalid element type. + if (module[name] === undefined) throw new ChunkLoadError(`The page file has no ${String(name)} export.`); + return { default: module[name] }; + }); } /** React.lazy through loadChunk. Every lazy component in the app goes through here. */ diff --git a/src/test/lib/lazy-chunk.test.ts b/src/test/lib/lazy-chunk.test.ts index 61af1ff7f..2e336c249 100644 --- a/src/test/lib/lazy-chunk.test.ts +++ b/src/test/lib/lazy-chunk.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { isChunkLoadError, loadChunk } from '@/lib/lazy-chunk'; +import { isChunkLoadError, loadChunk, namedChunk } from '@/lib/lazy-chunk'; import { resetStaleChunkReloadForTests } from '@/lib/reload-stale-chunk'; const PENDING = Symbol('pending'); @@ -49,6 +49,10 @@ describe('loadChunk', () => { expect(reload).not.toHaveBeenCalled(); }); + it('fails with a named error when a module lacks the export a lazy component asked for', async () => { + await expect(namedChunk(() => Promise.resolve({ AppLayout: (): null => null }), 'Layout' as 'AppLayout')).rejects.toMatchObject({ name: 'ChunkLoadError' }); + }); + it('recognizes each browser’s missing-module message', () => { expect(isChunkLoadError(missingChunk())).toBe(true); expect(isChunkLoadError(new TypeError('error loading dynamically imported module: /assets/a.js'))).toBe(true); From e604dd7c998308f5b60753da85d613a8497e9a0a Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 20:51:50 +0000 Subject: [PATCH 5/5] Count first_ai_success only for new accounts, let a later deploy reload again, and plainer sign-in copy --- docs/architecture.md | 4 ++-- src/App.tsx | 5 +++-- src/lib/activation-events.ts | 28 ++++++++++++++++++------- src/lib/reload-stale-chunk.ts | 18 ++++++++++++---- src/locales/en.json | 2 +- src/locales/es.json | 4 ++-- src/locales/hu.json | 4 ++-- src/test/lib/activation-events.test.ts | 20 +++++++++++++++--- src/test/lib/reload-stale-chunk.test.ts | 21 ++++++++++++++++++- 9 files changed, 82 insertions(+), 24 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index f53c46246..ac1691b8f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -170,9 +170,9 @@ A run that produces nothing costs nothing. When an `ai` request fails on the ser Client startup loads Sentry and PostHog asynchronously when configured. Product events and exceptions use the privacy-filtering facade in [`src/lib/observability.ts`](../src/lib/observability.ts), so SDK loading or failure cannot block rendering. Sentry events carry the build's commit SHA as the release and the Vercel environment name. Route crashes keep the original error type and stack frames but replace the message, which can carry user text. Vercel Analytics and Speed Insights run only in the web build and load outside the application render boundary. -The facade accepts only the event names and property values listed in it. `ai_request_failed` says why a request failed: `tool`, `error_code` (one of `limit_reached`, `model_restricted`, `auth_expired`, `timeout`, `network`, `aborted`, `provider_error`, `invalid_response`, `server_error`, `unknown`, mapped from the client's AI error classes in [`src/lib/ai/failure-reason.ts`](../src/lib/ai/failure-reason.ts)), `http_status`, `stage` (`request`, `stream`, `parse`), `streaming` and `duration_ms`. The same file decides each failure's next step: the plan dialog, Sign in again, or Retry. The activation events in [`src/lib/activation-events.ts`](../src/lib/activation-events.ts) are `signup_completed` (`method`), `trial_started`, `paywall_shown` (`reason`, `tool`), `first_ai_success` (`tool`) and `welcome_completed`. The once-per-account ones remember what they sent in this browser's local storage. PostHog keeps its own state in memory and is never told who the user is, so it cannot join one student's events across page loads. +The facade accepts only the event names and property values listed in it. `ai_request_failed` says why a request failed: `tool`, `error_code` (one of `limit_reached`, `model_restricted`, `auth_expired`, `timeout`, `network`, `aborted`, `provider_error`, `invalid_response`, `server_error`, `unknown`, mapped from the client's AI error classes in [`src/lib/ai/failure-reason.ts`](../src/lib/ai/failure-reason.ts)), `http_status`, `stage` (`request`, `stream`, `parse`), `streaming` and `duration_ms`. The same file decides each failure's next step: the plan dialog, Sign in again, or Retry. The activation events in [`src/lib/activation-events.ts`](../src/lib/activation-events.ts) are `signup_completed` (`method`), `trial_started`, `paywall_shown` (`reason`, `tool`), `first_ai_success` (`tool`) and `welcome_completed`. The once-per-account ones remember what they sent in this browser's local storage. `first_ai_success` is sent only for an account made in the last seven days, so an older account's next answer is not counted as its first. PostHog keeps its own state in memory and is never told who the user is, so it cannot join one student's events across page loads. -Every `React.lazy` component and route loader goes through `loadChunk` in [`src/lib/lazy-chunk.ts`](../src/lib/lazy-chunk.ts). After a deploy removes an old file, the page reloads once per path (guarded in `sessionStorage`, shared with the `vite:preloadError` listener) instead of crashing on a module that loaded as nothing. +Every `React.lazy` component and route loader goes through `loadChunk` in [`src/lib/lazy-chunk.ts`](../src/lib/lazy-chunk.ts). After a deploy removes an old file, the page reloads once per path in ten minutes (the time is kept in `sessionStorage` and shared with the `vite:preloadError` listener, so a file still missing after the reload stays an error while a later deploy can reload again) instead of crashing on a module that loaded as nothing. CI and staging must not upload authenticated browser captures. Logging must not expose tokens, provider keys, user content, or protected staging values. diff --git a/src/App.tsx b/src/App.tsx index d043929de..32d489de6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,7 +19,7 @@ import { focusCurrentDebate, noteTourChunkFailure, tourChunkGaveUp } from "@/lib import { OfflineIndicator } from "@/components/OfflineIndicator"; import { StagingBanner } from "@/components/StagingBanner"; import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability"; -import { captureSignupCompleted, captureTrialStarted, captureWelcomeCompleted } from "@/lib/activation-events"; +import { captureSignupCompleted, captureTrialStarted, captureWelcomeCompleted, rememberActivationAccount } from "@/lib/activation-events"; import { useSubscription } from "@/hooks/use-subscription"; import { lazyChunk, loadChunk, namedChunk } from "@/lib/lazy-chunk"; import { appLayoutLoader, localizedRouteLoaders, preloadStartupRoute, routeLoaders, type RouteLoader } from "@/lib/route-modules"; @@ -273,12 +273,13 @@ export function ToolRouteAnalytics(): null { return null; } -/** signup_completed and trial_started, once per new account. AppLayout's trial banner shares this subscription read. */ +/** signup_completed and trial_started, once per new account, and the account first_ai_success checks. AppLayout's trial banner shares this subscription read. */ export function ActivationAnalytics(): null { const { user } = useAuth(); const { tier, loading } = useSubscription(); useEffect(() => { + rememberActivationAccount(user ?? null); if (user) captureSignupCompleted(user); }, [user]); diff --git a/src/lib/activation-events.ts b/src/lib/activation-events.ts index 848271f05..d847f6af3 100644 --- a/src/lib/activation-events.ts +++ b/src/lib/activation-events.ts @@ -55,17 +55,31 @@ function signedInUserId(): string | null { } } -/** The first answer an account gets from any tool. */ -export function captureFirstAISuccess(tool: string): void { - captureOncePerAccount('first_ai_success', signedInUserId(), { tool }); -} - export interface ActivationUser { id: string; created_at?: string; app_metadata?: Record; } +// A first answer counts only for an account this young. Older accounts had +// answers before this event existed, so their next one is not their first. +const FIRST_SUCCESS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +let activationAccount: ActivationUser | null = null; + +/** The signed-in account, so captureFirstAISuccess can tell a new account from an old one. */ +export function rememberActivationAccount(user: ActivationUser | null): void { + activationAccount = user; +} + +/** The first answer a new account gets from any tool. */ +export function captureFirstAISuccess(tool: string, now = Date.now()): void { + const accountId = signedInUserId(); + if (!accountId || activationAccount?.id !== accountId) return; + if (!isNewAccount(activationAccount, now, FIRST_SUCCESS_WINDOW_MS)) return; + captureOncePerAccount('first_ai_success', accountId, { tool }); +} + export type SignupMethod = 'demo' | 'email' | 'google'; export function signupMethod(user: ActivationUser): SignupMethod | null { @@ -76,9 +90,9 @@ export function signupMethod(user: ActivationUser): SignupMethod | null { return null; } -export function isNewAccount(user: ActivationUser, now = Date.now()): boolean { +export function isNewAccount(user: ActivationUser, now = Date.now(), windowMs = NEW_ACCOUNT_WINDOW_MS): boolean { const created = user.created_at ? Date.parse(user.created_at) : NaN; - return Number.isFinite(created) && now - created >= 0 && now - created <= NEW_ACCOUNT_WINDOW_MS; + return Number.isFinite(created) && now - created >= 0 && now - created <= windowMs; } /** A first sign-in to an account created in the last day. Returns whether it was sent. */ diff --git a/src/lib/reload-stale-chunk.ts b/src/lib/reload-stale-chunk.ts index b9bf29458..b5a54b515 100644 --- a/src/lib/reload-stale-chunk.ts +++ b/src/lib/reload-stale-chunk.ts @@ -1,4 +1,8 @@ const RELOAD_PREFIX = 'debatelab-stale-chunk:'; +// One automatic reload per path in this window. A file still missing after +// the reload stays an error instead of refreshing the tab again, while a +// later deploy in the same tab can still reload once. +export const STALE_CHUNK_RELOAD_WINDOW_MS = 10 * 60 * 1000; type ReloadTarget = Pick & { navigator: Pick; @@ -19,17 +23,23 @@ export function resetStaleChunkReloadForTests(): void { /** * Reloads the page once per path for a chunk the deployed build no longer has. * Returns whether a reload was started. A second failure on the same path - * stays put, so a missing file cannot refresh the tab forever. Offline the + * within STALE_CHUNK_RELOAD_WINDOW_MS stays put, so a missing file cannot + * refresh the tab forever. Offline the * chunk is not stale, only unreachable: a reload could replace the page with * the browser's offline error, so the error stands instead. */ -export function reloadOnceForStaleChunk(target: ReloadTarget = window): boolean { +export function reloadOnceForStaleChunk(target: ReloadTarget = window, now: number = Date.now()): boolean { if (reloadRequested) return true; if (target.navigator.onLine === false) return false; const key = `${RELOAD_PREFIX}${target.location.pathname || '/'}`; try { - if (target.sessionStorage.getItem(key) === '1') return false; - target.sessionStorage.setItem(key, '1'); + // The value is when this path last reloaded. A '1' from an older build has no time and counts as old. + const stored = target.sessionStorage.getItem(key); + if (stored !== null && stored !== '1') { + const last = Number(stored); + if (!Number.isFinite(last) || now - last < STALE_CHUNK_RELOAD_WINDOW_MS) return false; + } + target.sessionStorage.setItem(key, String(now)); } catch { return false; } diff --git a/src/locales/en.json b/src/locales/en.json index be6ecc1e9..a7b1b1d65 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2690,7 +2690,7 @@ }, "serverErrors": { "signInRequired": "Please sign in to continue.", - "sessionExpired": "Authentication failed. Your session may have expired. Please sign in again.", + "sessionExpired": "We couldn't confirm you're signed in. Sign in again to keep going.", "accessDenied": "Access denied. Your session may have expired. Please sign in again.", "banned": "Your account has been suspended. Please contact support.", "requestTooLarge": "Request is too large. Shorten the input and try again.", diff --git a/src/locales/es.json b/src/locales/es.json index 02187df62..6cc7dbfef 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2597,7 +2597,7 @@ "unexpected": "Se produjo un error inesperado", "offline": "Comprueba tu conexión e inténtalo de nuevo.", "notCharged": "No se te ha cobrado esta solicitud.", - "signInAgain": "Volver a iniciar sesión" + "signInAgain": "Vuelve a iniciar sesión" }, "clock": { "timeUp": "Se acabó el tiempo.", @@ -2690,7 +2690,7 @@ }, "serverErrors": { "signInRequired": "Inicia sesión para continuar.", - "sessionExpired": "No se pudo verificar tu sesión. Puede que haya caducado. Vuelve a iniciar sesión.", + "sessionExpired": "No pudimos comprobar que has iniciado sesión. Vuelve a iniciar sesión para continuar.", "accessDenied": "Acceso denegado. Puede que tu sesión haya caducado. Vuelve a iniciar sesión.", "banned": "Tu cuenta está suspendida. Ponte en contacto con soporte.", "requestTooLarge": "La solicitud es demasiado grande. Acorta el texto e inténtalo de nuevo.", diff --git a/src/locales/hu.json b/src/locales/hu.json index e752f52f4..171396dae 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2597,7 +2597,7 @@ "unexpected": "Váratlan hiba történt", "offline": "Ellenőrizd a kapcsolatot, és próbáld újra.", "notCharged": "Ezt a kérést nem számoltuk el.", - "signInAgain": "Bejelentkezés újra" + "signInAgain": "Jelentkezz be újra" }, "clock": { "timeUp": "Lejárt az idő.", @@ -2690,7 +2690,7 @@ }, "serverErrors": { "signInRequired": "A folytatáshoz jelentkezz be.", - "sessionExpired": "A hitelesítés nem sikerült. Lehet, hogy lejárt a munkameneted. Jelentkezz be újra.", + "sessionExpired": "Nem sikerült ellenőrizni, hogy be vagy-e jelentkezve. Jelentkezz be újra a folytatáshoz.", "accessDenied": "Hozzáférés megtagadva. Lehet, hogy lejárt a munkameneted. Jelentkezz be újra.", "banned": "A fiókodat felfüggesztettük. Fordulj az ügyfélszolgálathoz.", "requestTooLarge": "A kérés túl nagy. Rövidítsd le a szöveget, és próbáld újra.", diff --git a/src/test/lib/activation-events.test.ts b/src/test/lib/activation-events.test.ts index b7f3d0e4b..71fa2905f 100644 --- a/src/test/lib/activation-events.test.ts +++ b/src/test/lib/activation-events.test.ts @@ -11,6 +11,7 @@ vi.mock('@/lib/supabase/client', () => ({ getActiveUserId: () => activeUser.id } import { captureFirstAISuccess, captureOncePerAccount, + rememberActivationAccount, captureSignupCompleted, captureTrialStarted, captureWelcomeCompleted, @@ -28,14 +29,17 @@ describe('activation events', () => { localStorage.clear(); captureProductEventMock.mockReset(); activeUser.id = null; + rememberActivationAccount(null); }); it('sends first_ai_success once per account, and again for another account', () => { activeUser.id = 'user-a'; - captureFirstAISuccess('ask'); - captureFirstAISuccess('generate'); + rememberActivationAccount(newUser('user-a', { provider: 'email' })); + captureFirstAISuccess('ask', NOW); + captureFirstAISuccess('generate', NOW); activeUser.id = 'user-b'; - captureFirstAISuccess('crossfire'); + rememberActivationAccount(newUser('user-b', { provider: 'google' })); + captureFirstAISuccess('crossfire', NOW); expect(sent('first_ai_success')).toEqual([ ['first_ai_success', { tool: 'ask' }], @@ -43,6 +47,16 @@ describe('activation events', () => { ]); }); + it('does not count an older account\'s next answer as its first', () => { + activeUser.id = 'old-user'; + rememberActivationAccount({ id: 'old-user', created_at: '2026-09-01T00:00:00Z' }); + captureFirstAISuccess('ask', NOW); + // An account the page has not read yet is not guessed at either. + activeUser.id = 'unknown-user'; + captureFirstAISuccess('ask', NOW); + expect(sent('first_ai_success')).toHaveLength(0); + }); + it('sends nothing without an account or when storage cannot remember', () => { captureFirstAISuccess('ask'); const blocked = { getItem: () => { throw new Error('blocked'); }, setItem: (): void => undefined }; diff --git a/src/test/lib/reload-stale-chunk.test.ts b/src/test/lib/reload-stale-chunk.test.ts index 4bd9b2c21..78d8c2f75 100644 --- a/src/test/lib/reload-stale-chunk.test.ts +++ b/src/test/lib/reload-stale-chunk.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { installStaleChunkReload, resetStaleChunkReloadForTests } from '@/lib/reload-stale-chunk'; +import { STALE_CHUNK_RELOAD_WINDOW_MS, installStaleChunkReload, reloadOnceForStaleChunk, resetStaleChunkReloadForTests } from '@/lib/reload-stale-chunk'; function fakeWindow() { const listeners = new Map void>(); @@ -45,6 +45,25 @@ describe('stale chunk reload', () => { expect(reload).toHaveBeenCalledTimes(1); }); + it('reloads again for a later deploy, but not twice inside the window', () => { + const { target, reload } = fakeWindow(); + const start = 1_000_000; + const asWindow = target as unknown as Window; + expect(reloadOnceForStaleChunk(asWindow, start)).toBe(true); + resetStaleChunkReloadForTests(); + expect(reloadOnceForStaleChunk(asWindow, start + 5_000)).toBe(false); + resetStaleChunkReloadForTests(); + expect(reloadOnceForStaleChunk(asWindow, start + STALE_CHUNK_RELOAD_WINDOW_MS + 1)).toBe(true); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it('treats a value it cannot read as a recent reload', () => { + const { target, reload } = fakeWindow(); + target.sessionStorage.setItem('debatelab-stale-chunk:/app/ask', 'not-a-time'); + expect(reloadOnceForStaleChunk(target as unknown as Window)).toBe(false); + expect(reload).not.toHaveBeenCalled(); + }); + it('never reloads while offline, where a missing chunk is unreachable rather than stale', () => { const { target, listeners, reload } = fakeWindow(); target.navigator.onLine = false;