mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge branch 'feat/graceful-degradation' into feat/on-demand-ai-features
This commit is contained in:
+76
-16
@@ -1,39 +1,50 @@
|
|||||||
import { Component, type ErrorInfo, lazy, type ReactNode, Suspense } from "react";
|
import { Component, type ErrorInfo, type ReactNode, Suspense } from "react";
|
||||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
import { ConnectionBanner } from "./components/common/connection-banner";
|
||||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||||
import { useAuth } from "./hooks/use-auth";
|
import { useAuth } from "./hooks/use-auth";
|
||||||
|
import { useConnectionMonitor } from "./hooks/use-connection-monitor";
|
||||||
|
import { isChunkError, lazyWithRetry } from "./lib/lazy-with-retry";
|
||||||
|
|
||||||
// Lazy-load all pages so each page's JS (and its icons/deps) is only
|
// Lazy-load all pages with automatic retry so chunk failures from
|
||||||
// downloaded when the user navigates there, shrinking the main bundle.
|
// deployments are recovered transparently instead of white-screening.
|
||||||
const AutomatePage = lazy(() =>
|
const AutomatePage = lazyWithRetry(() =>
|
||||||
import("./pages/automate-page").then((m) => ({ default: m.AutomatePage })),
|
import("./pages/automate-page").then((m) => ({ default: m.AutomatePage })),
|
||||||
);
|
);
|
||||||
const ChangePasswordPage = lazy(() =>
|
const ChangePasswordPage = lazyWithRetry(() =>
|
||||||
import("./pages/change-password-page").then((m) => ({ default: m.ChangePasswordPage })),
|
import("./pages/change-password-page").then((m) => ({ default: m.ChangePasswordPage })),
|
||||||
);
|
);
|
||||||
const FilesPage = lazy(() => import("./pages/files-page").then((m) => ({ default: m.FilesPage })));
|
const FilesPage = lazyWithRetry(() =>
|
||||||
const FullscreenGridPage = lazy(() =>
|
import("./pages/files-page").then((m) => ({ default: m.FilesPage })),
|
||||||
|
);
|
||||||
|
const FullscreenGridPage = lazyWithRetry(() =>
|
||||||
import("./pages/fullscreen-grid-page").then((m) => ({ default: m.FullscreenGridPage })),
|
import("./pages/fullscreen-grid-page").then((m) => ({ default: m.FullscreenGridPage })),
|
||||||
);
|
);
|
||||||
const HomePage = lazy(() => import("./pages/home-page").then((m) => ({ default: m.HomePage })));
|
const HomePage = lazyWithRetry(() =>
|
||||||
const LoginPage = lazy(() => import("./pages/login-page").then((m) => ({ default: m.LoginPage })));
|
import("./pages/home-page").then((m) => ({ default: m.HomePage })),
|
||||||
const PrivacyPolicyPage = lazy(() =>
|
);
|
||||||
|
const LoginPage = lazyWithRetry(() =>
|
||||||
|
import("./pages/login-page").then((m) => ({ default: m.LoginPage })),
|
||||||
|
);
|
||||||
|
const PrivacyPolicyPage = lazyWithRetry(() =>
|
||||||
import("./pages/privacy-policy-page").then((m) => ({ default: m.PrivacyPolicyPage })),
|
import("./pages/privacy-policy-page").then((m) => ({ default: m.PrivacyPolicyPage })),
|
||||||
);
|
);
|
||||||
const ToolPage = lazy(() => import("./pages/tool-page").then((m) => ({ default: m.ToolPage })));
|
const ToolPage = lazyWithRetry(() =>
|
||||||
|
import("./pages/tool-page").then((m) => ({ default: m.ToolPage })),
|
||||||
|
);
|
||||||
|
|
||||||
class ErrorBoundary extends Component<
|
class ErrorBoundary extends Component<
|
||||||
{ children: ReactNode },
|
{ children: ReactNode },
|
||||||
{ hasError: boolean; error: Error | null }
|
{ hasError: boolean; error: Error | null; isChunkError: boolean }
|
||||||
> {
|
> {
|
||||||
constructor(props: { children: ReactNode }) {
|
constructor(props: { children: ReactNode }) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { hasError: false, error: null };
|
this.state = { hasError: false, error: null, isChunkError: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error) {
|
static getDerivedStateFromError(error: Error) {
|
||||||
return { hasError: true, error };
|
return { hasError: true, error, isChunkError: isChunkError(error) };
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
@@ -42,6 +53,43 @@ class ErrorBoundary extends Component<
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
|
if (this.state.isChunkError) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||||
|
<div className="text-center space-y-4 max-w-md px-6">
|
||||||
|
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-primary"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
role="img"
|
||||||
|
aria-label="Refresh icon"
|
||||||
|
>
|
||||||
|
<title>Refresh</title>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold">Update Available</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
A new version of ashim has been deployed.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||||
<div className="text-center space-y-4 max-w-md px-6">
|
<div className="text-center space-y-4 max-w-md px-6">
|
||||||
@@ -52,7 +100,7 @@ class ErrorBoundary extends Component<
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
this.setState({ hasError: false, error: null });
|
this.setState({ hasError: false, error: null, isChunkError: false });
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium"
|
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium"
|
||||||
@@ -112,8 +160,16 @@ function PageLoader() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConnectionMonitor() {
|
||||||
|
useConnectionMonitor();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ConnectionMonitor />
|
||||||
|
<ConnectionBanner />
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Toaster position="bottom-right" />
|
<Toaster position="bottom-right" />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@@ -133,7 +189,10 @@ export function App() {
|
|||||||
element={<Navigate to="/adjust-colors" replace />}
|
element={<Navigate to="/adjust-colors" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="/saturation" element={<Navigate to="/adjust-colors" replace />} />
|
<Route path="/saturation" element={<Navigate to="/adjust-colors" replace />} />
|
||||||
<Route path="/color-channels" element={<Navigate to="/adjust-colors" replace />} />
|
<Route
|
||||||
|
path="/color-channels"
|
||||||
|
element={<Navigate to="/adjust-colors" replace />}
|
||||||
|
/>
|
||||||
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
|
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
|
||||||
<Route path="/:toolId" element={<ToolPage />} />
|
<Route path="/:toolId" element={<ToolPage />} />
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
@@ -143,5 +202,6 @@ export function App() {
|
|||||||
</KeyboardShortcutProvider>
|
</KeyboardShortcutProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { CheckCircle2, Loader2, WifiOff } from "lucide-react";
|
||||||
|
import { useConnectionStore } from "@/stores/connection-store";
|
||||||
|
|
||||||
|
export function ConnectionBanner() {
|
||||||
|
const status = useConnectionStore((s) => s.status);
|
||||||
|
|
||||||
|
if (status === "connected") return null;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
disconnected: {
|
||||||
|
bg: "bg-amber-500 dark:bg-amber-600",
|
||||||
|
text: "text-amber-950 dark:text-amber-50",
|
||||||
|
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||||
|
message: "Reconnecting to server\u2026",
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
bg: "bg-amber-500 dark:bg-amber-600",
|
||||||
|
text: "text-amber-950 dark:text-amber-50",
|
||||||
|
icon: <WifiOff className="h-4 w-4" />,
|
||||||
|
message: "You\u2019re offline",
|
||||||
|
},
|
||||||
|
reconnected: {
|
||||||
|
bg: "bg-emerald-500 dark:bg-emerald-600",
|
||||||
|
text: "text-emerald-950 dark:text-emerald-50",
|
||||||
|
icon: <CheckCircle2 className="h-4 w-4" />,
|
||||||
|
message: "Connected",
|
||||||
|
},
|
||||||
|
}[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className={`fixed top-0 left-0 right-0 z-[60] flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium transition-transform duration-300 ${config.bg} ${config.text}`}
|
||||||
|
>
|
||||||
|
{config.icon}
|
||||||
|
{config.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
|
|||||||
import { useMobile } from "@/hooks/use-mobile";
|
import { useMobile } from "@/hooks/use-mobile";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useConnectionStore } from "@/stores/connection-store";
|
||||||
import { Dropzone } from "../common/dropzone";
|
import { Dropzone } from "../common/dropzone";
|
||||||
import { GemLogo } from "../common/gem-logo";
|
import { GemLogo } from "../common/gem-logo";
|
||||||
import { HelpDialog } from "../help/help-dialog";
|
import { HelpDialog } from "../help/help-dialog";
|
||||||
@@ -25,6 +26,8 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
|||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
const [customLogo, setCustomLogo] = useState(false);
|
const [customLogo, setCustomLogo] = useState(false);
|
||||||
|
const connectionStatus = useConnectionStore((s) => s.status);
|
||||||
|
const bannerVisible = connectionStatus !== "connected";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
||||||
@@ -33,7 +36,12 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background text-foreground overflow-hidden">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-screen bg-background text-foreground overflow-hidden",
|
||||||
|
bannerVisible && "pt-9",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -92,7 +100,12 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
|||||||
|
|
||||||
{/* Mobile top bar */}
|
{/* Mobile top bar */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="fixed top-0 left-0 right-0 z-30 bg-background/95 backdrop-blur-sm border-b border-border px-3 py-2 flex items-center gap-3">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed left-0 right-0 z-30 bg-background/95 backdrop-blur-sm border-b border-border px-3 py-2 flex items-center gap-3",
|
||||||
|
bannerVisible ? "top-9" : "top-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMobileSidebarOpen(true)}
|
onClick={() => setMobileSidebarOpen(true)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
import { useConnectionStore } from "@/stores/connection-store";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -36,13 +37,15 @@ export function useAuth() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
try {
|
try {
|
||||||
// Check if auth is enabled
|
|
||||||
const configRes = await fetch("/api/v1/config/auth");
|
const configRes = await fetch("/api/v1/config/auth");
|
||||||
const config = await configRes.json();
|
const config = await configRes.json();
|
||||||
|
|
||||||
if (!config.authEnabled) {
|
if (!config.authEnabled) {
|
||||||
|
if (!cancelled)
|
||||||
setState({
|
setState({
|
||||||
loading: false,
|
loading: false,
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
@@ -54,9 +57,9 @@ export function useAuth() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth is enabled — check if we have a valid session
|
|
||||||
const token = localStorage.getItem("ashim-token");
|
const token = localStorage.getItem("ashim-token");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
if (!cancelled)
|
||||||
setState({
|
setState({
|
||||||
loading: false,
|
loading: false,
|
||||||
authEnabled: true,
|
authEnabled: true,
|
||||||
@@ -75,6 +78,7 @@ export function useAuth() {
|
|||||||
if (sessionRes.ok) {
|
if (sessionRes.ok) {
|
||||||
const session = await sessionRes.json();
|
const session = await sessionRes.json();
|
||||||
const mustChange = session.user?.mustChangePassword === true;
|
const mustChange = session.user?.mustChangePassword === true;
|
||||||
|
if (!cancelled)
|
||||||
setState({
|
setState({
|
||||||
loading: false,
|
loading: false,
|
||||||
authEnabled: true,
|
authEnabled: true,
|
||||||
@@ -85,6 +89,7 @@ export function useAuth() {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem("ashim-token");
|
localStorage.removeItem("ashim-token");
|
||||||
|
if (!cancelled)
|
||||||
setState({
|
setState({
|
||||||
loading: false,
|
loading: false,
|
||||||
authEnabled: true,
|
authEnabled: true,
|
||||||
@@ -95,19 +100,23 @@ export function useAuth() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Can't reach API — assume no auth needed (dev mode)
|
// API unreachable — stay in loading state.
|
||||||
setState({
|
// ConnectionBanner explains the outage. AuthGuard shows spinner.
|
||||||
loading: false,
|
|
||||||
authEnabled: false,
|
|
||||||
isAuthenticated: true,
|
|
||||||
mustChangePassword: false,
|
|
||||||
role: "admin",
|
|
||||||
permissions: ALL_PERMISSIONS,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkAuth();
|
checkAuth();
|
||||||
|
|
||||||
|
const unsubscribe = useConnectionStore.subscribe((curr, prev) => {
|
||||||
|
if (prev.status !== "reconnected" && curr.status === "reconnected") {
|
||||||
|
checkAuth();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const hasPermission = (permission: string) => state.permissions.includes(permission);
|
const hasPermission = (permission: string) => state.permissions.includes(permission);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useConnectionStore } from "@/stores/connection-store";
|
||||||
|
|
||||||
|
export function useConnectionMonitor() {
|
||||||
|
useEffect(() => {
|
||||||
|
const store = useConnectionStore;
|
||||||
|
|
||||||
|
const handleOffline = () => store.getState().setOffline();
|
||||||
|
const handleOnline = () => {
|
||||||
|
const prev = store.getState().status;
|
||||||
|
store.getState().setOnline();
|
||||||
|
if (prev === "offline") {
|
||||||
|
store.getState().startPolling();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("offline", handleOffline);
|
||||||
|
window.addEventListener("online", handleOnline);
|
||||||
|
|
||||||
|
store.getState().checkHealth();
|
||||||
|
|
||||||
|
const unsubscribe = store.subscribe((state, prev) => {
|
||||||
|
if (state.status === prev.status) return;
|
||||||
|
|
||||||
|
if (state.status === "disconnected") {
|
||||||
|
store.getState().startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "reconnected") {
|
||||||
|
store.getState().stopPolling();
|
||||||
|
store
|
||||||
|
.getState()
|
||||||
|
.refreshStaleData()
|
||||||
|
.finally(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (store.getState().status === "reconnected") {
|
||||||
|
store.setState({ status: "connected" });
|
||||||
|
}
|
||||||
|
}, 2500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "offline") {
|
||||||
|
store.getState().stopPolling();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("offline", handleOffline);
|
||||||
|
window.removeEventListener("online", handleOnline);
|
||||||
|
store.getState().stopPolling();
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -261,7 +261,7 @@ export function useToolProcessor(toolId: string) {
|
|||||||
eventSourceRef.current.close();
|
eventSourceRef.current.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
}
|
}
|
||||||
setError("Network error - check your connection");
|
setError("Processing was interrupted \u2014 retry when reconnected");
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
setProgress(IDLE_PROGRESS);
|
setProgress(IDLE_PROGRESS);
|
||||||
};
|
};
|
||||||
|
|||||||
+74
-8
@@ -1,3 +1,5 @@
|
|||||||
|
import { useConnectionStore } from "@/stores/connection-store";
|
||||||
|
|
||||||
const API_BASE = "/api";
|
const API_BASE = "/api";
|
||||||
|
|
||||||
export interface FeatureNotInstalledError {
|
export interface FeatureNotInstalledError {
|
||||||
@@ -74,9 +76,17 @@ async function throwWithMessage(res: Response): Promise<never> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiGet<T>(path: string): Promise<T> {
|
export async function apiGet<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_BASE}${path}`, {
|
||||||
headers: formatHeaders(),
|
headers: formatHeaders(),
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TypeError) {
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (!res.ok) await throwWithMessage(res);
|
if (!res.ok) await throwWithMessage(res);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -84,30 +94,54 @@ export async function apiGet<T>(path: string): Promise<T> {
|
|||||||
export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
|
export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const headers =
|
const headers =
|
||||||
body !== undefined ? formatHeaders({ "Content-Type": "application/json" }) : formatHeaders();
|
body !== undefined ? formatHeaders({ "Content-Type": "application/json" }) : formatHeaders();
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_BASE}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TypeError) {
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (!res.ok) await throwWithMessage(res);
|
if (!res.ok) await throwWithMessage(res);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPut<T>(path: string, body?: unknown): Promise<T> {
|
export async function apiPut<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_BASE}${path}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TypeError) {
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (!res.ok) await throwWithMessage(res);
|
if (!res.ok) await throwWithMessage(res);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDelete<T>(path: string): Promise<T> {
|
export async function apiDelete<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_BASE}${path}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: formatHeaders(),
|
headers: formatHeaders(),
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TypeError) {
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (!res.ok) await throwWithMessage(res);
|
if (!res.ok) await throwWithMessage(res);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -128,11 +162,19 @@ export async function apiUpload(files: File[]): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
for (const f of files) formData.append("files", f);
|
for (const f of files) formData.append("files", f);
|
||||||
const res = await fetch("/api/v1/upload", {
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch("/api/v1/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: formatHeaders(),
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TypeError) {
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -190,21 +232,37 @@ export async function apiUploadUserFiles(
|
|||||||
): Promise<{ files: Array<{ id: string; originalName: string; size: number; version: number }> }> {
|
): Promise<{ files: Array<{ id: string; originalName: string; size: number; version: number }> }> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
for (const f of files) formData.append("files", f);
|
for (const f of files) formData.append("files", f);
|
||||||
const res = await fetch("/api/v1/files/upload", {
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch("/api/v1/files/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: formatHeaders(),
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TypeError) {
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDeleteUserFiles(ids: string[]): Promise<{ deleted: number }> {
|
export async function apiDeleteUserFiles(ids: string[]): Promise<{ deleted: number }> {
|
||||||
const res = await fetch("/api/v1/files", {
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch("/api/v1/files", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||||
body: JSON.stringify({ ids }),
|
body: JSON.stringify({ ids }),
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TypeError) {
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -218,9 +276,17 @@ export function getFileDownloadUrl(id: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDownloadBlob(jobId: string, filename: string): Promise<Blob> {
|
export async function apiDownloadBlob(jobId: string, filename: string): Promise<Blob> {
|
||||||
const res = await fetch(getDownloadUrl(jobId, filename), {
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(getDownloadUrl(jobId, filename), {
|
||||||
headers: formatHeaders(),
|
headers: formatHeaders(),
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TypeError) {
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||||||
return res.blob();
|
return res.blob();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { type ComponentType, lazy } from "react";
|
||||||
|
import { useConnectionStore } from "@/stores/connection-store";
|
||||||
|
|
||||||
|
export function isChunkError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) return false;
|
||||||
|
const msg = error.message.toLowerCase();
|
||||||
|
return (
|
||||||
|
msg.includes("dynamically imported module") ||
|
||||||
|
msg.includes("loading chunk") ||
|
||||||
|
msg.includes("loading css chunk") ||
|
||||||
|
msg.includes("failed to fetch") ||
|
||||||
|
msg.includes("unable to preload")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryDynamicImport<T>(
|
||||||
|
importFn: () => Promise<T>,
|
||||||
|
retries = 3,
|
||||||
|
delay = 1000,
|
||||||
|
): Promise<T> {
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await importFn();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isChunkError(error) || attempt === retries) {
|
||||||
|
if (isChunkError(error)) {
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("unreachable");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lazyWithRetry<T extends ComponentType<unknown>>(
|
||||||
|
importFn: () => Promise<{ default: T }>,
|
||||||
|
): React.LazyExoticComponent<T> {
|
||||||
|
return lazy(() => retryDynamicImport(importFn));
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
type ConnectionStatus = "connected" | "disconnected" | "reconnected" | "offline";
|
||||||
|
|
||||||
|
interface ConnectionState {
|
||||||
|
status: ConnectionStatus;
|
||||||
|
failedSince: number | null;
|
||||||
|
lastHealthCheck: number | null;
|
||||||
|
|
||||||
|
setDisconnected: () => void;
|
||||||
|
setOffline: () => void;
|
||||||
|
setOnline: () => void;
|
||||||
|
checkHealth: () => Promise<void>;
|
||||||
|
startPolling: () => void;
|
||||||
|
stopPolling: () => void;
|
||||||
|
refreshStaleData: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||||
|
status: "connected",
|
||||||
|
failedSince: null,
|
||||||
|
lastHealthCheck: null,
|
||||||
|
|
||||||
|
setDisconnected: () => {
|
||||||
|
const current = get();
|
||||||
|
if (current.status === "disconnected") return;
|
||||||
|
set({
|
||||||
|
status: "disconnected",
|
||||||
|
failedSince: current.failedSince ?? Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setOffline: () => {
|
||||||
|
set({ status: "offline", failedSince: get().failedSince ?? Date.now() });
|
||||||
|
},
|
||||||
|
|
||||||
|
setOnline: () => {
|
||||||
|
if (get().status !== "offline") return;
|
||||||
|
set({ status: "disconnected" });
|
||||||
|
},
|
||||||
|
|
||||||
|
checkHealth: async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/health");
|
||||||
|
if (res.ok) {
|
||||||
|
const current = get().status;
|
||||||
|
if (current === "disconnected" || current === "offline") {
|
||||||
|
set({ status: "reconnected", lastHealthCheck: Date.now(), failedSince: null });
|
||||||
|
} else {
|
||||||
|
set({ lastHealthCheck: Date.now() });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (get().status === "connected") {
|
||||||
|
get().setDisconnected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (get().status === "connected") {
|
||||||
|
get().setDisconnected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startPolling: () => {
|
||||||
|
if (pollingInterval) return;
|
||||||
|
pollingInterval = setInterval(() => {
|
||||||
|
get().checkHealth();
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopPolling: () => {
|
||||||
|
if (pollingInterval) {
|
||||||
|
clearInterval(pollingInterval);
|
||||||
|
pollingInterval = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshStaleData: async () => {
|
||||||
|
const { useSettingsStore } = await import("@/stores/settings-store");
|
||||||
|
const { useFeaturesStore } = await import("@/stores/features-store");
|
||||||
|
|
||||||
|
useSettingsStore.setState({ loaded: false });
|
||||||
|
await Promise.allSettled([
|
||||||
|
useSettingsStore.getState().fetch(),
|
||||||
|
useFeaturesStore.getState().refresh(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
|
||||||
|
import { useConnectionStore } from "@/stores/connection-store";
|
||||||
|
|
||||||
|
describe("ConnectionBanner", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
useConnectionStore.setState({ status: "connected", failedSince: null, lastHealthCheck: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when connected", async () => {
|
||||||
|
const { renderBanner } = await setupRender();
|
||||||
|
useConnectionStore.setState({ status: "connected" });
|
||||||
|
const { container } = renderBanner();
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders amber banner when disconnected", async () => {
|
||||||
|
const { renderBanner } = await setupRender();
|
||||||
|
useConnectionStore.setState({ status: "disconnected" });
|
||||||
|
const { container } = renderBanner();
|
||||||
|
expect(container.textContent).toContain("Reconnecting to server");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders offline banner when offline", async () => {
|
||||||
|
const { renderBanner } = await setupRender();
|
||||||
|
useConnectionStore.setState({ status: "offline" });
|
||||||
|
const { container } = renderBanner();
|
||||||
|
expect(container.textContent).toContain("offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders green banner when reconnected", async () => {
|
||||||
|
const { renderBanner } = await setupRender();
|
||||||
|
useConnectionStore.setState({ status: "reconnected" });
|
||||||
|
const { container } = renderBanner();
|
||||||
|
expect(container.textContent).toContain("Connected");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupRender() {
|
||||||
|
const React = await import("react");
|
||||||
|
const { render } = await import("@testing-library/react");
|
||||||
|
const { ConnectionBanner } = await import("@/components/common/connection-banner");
|
||||||
|
return {
|
||||||
|
renderBanner: () => render(React.createElement(ConnectionBanner)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
import { useConnectionStore } from "@/stores/connection-store";
|
||||||
|
|
||||||
|
function okHealth() {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ status: "healthy" }), { status: 200 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function failHealth() {
|
||||||
|
return Promise.reject(new TypeError("Failed to fetch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonOkHealth() {
|
||||||
|
return Promise.resolve(new Response(null, { status: 503 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("connection-store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
useConnectionStore.setState({
|
||||||
|
status: "connected",
|
||||||
|
failedSince: null,
|
||||||
|
lastHealthCheck: null,
|
||||||
|
});
|
||||||
|
fetchMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useConnectionStore.getState().stopPolling();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts in connected state", () => {
|
||||||
|
expect(useConnectionStore.getState().status).toBe("connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions to disconnected on setDisconnected", () => {
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
const state = useConnectionStore.getState();
|
||||||
|
expect(state.status).toBe("disconnected");
|
||||||
|
expect(state.failedSince).toBeTypeOf("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite failedSince on repeated setDisconnected calls", () => {
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
const first = useConnectionStore.getState().failedSince;
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
expect(useConnectionStore.getState().failedSince).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions to offline on setOffline", () => {
|
||||||
|
useConnectionStore.getState().setOffline();
|
||||||
|
expect(useConnectionStore.getState().status).toBe("offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions from offline to disconnected on setOnline", () => {
|
||||||
|
useConnectionStore.getState().setOffline();
|
||||||
|
useConnectionStore.getState().setOnline();
|
||||||
|
expect(useConnectionStore.getState().status).toBe("disconnected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checkHealth transitions disconnected → reconnected on success", async () => {
|
||||||
|
fetchMock.mockImplementation(okHealth);
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
await useConnectionStore.getState().checkHealth();
|
||||||
|
expect(useConnectionStore.getState().status).toBe("reconnected");
|
||||||
|
expect(useConnectionStore.getState().lastHealthCheck).toBeTypeOf("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checkHealth stays disconnected on failure", async () => {
|
||||||
|
fetchMock.mockImplementation(failHealth);
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
await useConnectionStore.getState().checkHealth();
|
||||||
|
expect(useConnectionStore.getState().status).toBe("disconnected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checkHealth is a no-op when already connected", async () => {
|
||||||
|
fetchMock.mockImplementation(okHealth);
|
||||||
|
await useConnectionStore.getState().checkHealth();
|
||||||
|
expect(useConnectionStore.getState().status).toBe("connected");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checkHealth transitions connected → disconnected on non-ok response", async () => {
|
||||||
|
fetchMock.mockImplementation(nonOkHealth);
|
||||||
|
expect(useConnectionStore.getState().status).toBe("connected");
|
||||||
|
await useConnectionStore.getState().checkHealth();
|
||||||
|
expect(useConnectionStore.getState().status).toBe("disconnected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checkHealth transitions connected → disconnected on fetch failure", async () => {
|
||||||
|
fetchMock.mockImplementation(failHealth);
|
||||||
|
expect(useConnectionStore.getState().status).toBe("connected");
|
||||||
|
await useConnectionStore.getState().checkHealth();
|
||||||
|
expect(useConnectionStore.getState().status).toBe("disconnected");
|
||||||
|
expect(useConnectionStore.getState().failedSince).toBeTypeOf("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checkHealth transitions offline → reconnected on success", async () => {
|
||||||
|
fetchMock.mockImplementation(okHealth);
|
||||||
|
useConnectionStore.getState().setOffline();
|
||||||
|
expect(useConnectionStore.getState().status).toBe("offline");
|
||||||
|
await useConnectionStore.getState().checkHealth();
|
||||||
|
expect(useConnectionStore.getState().status).toBe("reconnected");
|
||||||
|
expect(useConnectionStore.getState().failedSince).toBeNull();
|
||||||
|
expect(useConnectionStore.getState().lastHealthCheck).toBeTypeOf("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("startPolling is idempotent", () => {
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
useConnectionStore.getState().startPolling();
|
||||||
|
useConnectionStore.getState().startPolling();
|
||||||
|
fetchMock.mockImplementation(failHealth);
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stopPolling clears the interval", () => {
|
||||||
|
useConnectionStore.getState().setDisconnected();
|
||||||
|
fetchMock.mockImplementation(failHealth);
|
||||||
|
useConnectionStore.getState().startPolling();
|
||||||
|
useConnectionStore.getState().stopPolling();
|
||||||
|
vi.advanceTimersByTime(6000);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { retryDynamicImport } from "@/lib/lazy-with-retry";
|
||||||
|
|
||||||
|
describe("retryDynamicImport", () => {
|
||||||
|
it("resolves on first success", async () => {
|
||||||
|
const mod = { default: () => null };
|
||||||
|
const importFn = vi.fn().mockResolvedValue(mod);
|
||||||
|
const result = await retryDynamicImport(importFn);
|
||||||
|
expect(result).toBe(mod);
|
||||||
|
expect(importFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries on failure and resolves on eventual success", async () => {
|
||||||
|
const mod = { default: () => null };
|
||||||
|
const importFn = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new TypeError("Failed to fetch dynamically imported module"))
|
||||||
|
.mockRejectedValueOnce(new TypeError("Failed to fetch dynamically imported module"))
|
||||||
|
.mockResolvedValue(mod);
|
||||||
|
const result = await retryDynamicImport(importFn, 3, 0);
|
||||||
|
expect(result).toBe(mod);
|
||||||
|
expect(importFn).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects after all retries exhausted", async () => {
|
||||||
|
const err = new TypeError("Failed to fetch dynamically imported module");
|
||||||
|
const importFn = vi.fn().mockRejectedValue(err);
|
||||||
|
await expect(retryDynamicImport(importFn, 3, 0)).rejects.toThrow(
|
||||||
|
"Failed to fetch dynamically imported module",
|
||||||
|
);
|
||||||
|
expect(importFn).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries CSS preload errors", async () => {
|
||||||
|
const mod = { default: () => null };
|
||||||
|
const importFn = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new TypeError("Unable to preload CSS for /assets/tool-page-DDbXBANV.css"),
|
||||||
|
)
|
||||||
|
.mockResolvedValue(mod);
|
||||||
|
const result = await retryDynamicImport(importFn, 3, 0);
|
||||||
|
expect(result).toBe(mod);
|
||||||
|
expect(importFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only retries chunk-related errors, not other errors", async () => {
|
||||||
|
const err = new Error("Some other error");
|
||||||
|
const importFn = vi.fn().mockRejectedValue(err);
|
||||||
|
await expect(retryDynamicImport(importFn, 3, 0)).rejects.toThrow("Some other error");
|
||||||
|
expect(importFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -741,6 +741,40 @@ describe("API lib", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -- api network error → connection store ---------------------------------
|
||||||
|
|
||||||
|
describe("api network error → connection store", () => {
|
||||||
|
it("triggers disconnected state on TypeError from fetch", async () => {
|
||||||
|
const { useConnectionStore } = await import("@/stores/connection-store");
|
||||||
|
useConnectionStore.setState({
|
||||||
|
status: "connected",
|
||||||
|
failedSince: null,
|
||||||
|
lastHealthCheck: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||||
|
await expect(apiGet("/v1/test")).rejects.toThrow("Failed to fetch");
|
||||||
|
|
||||||
|
expect(useConnectionStore.getState().status).toBe("disconnected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT trigger disconnected on HTTP errors", async () => {
|
||||||
|
const { useConnectionStore } = await import("@/stores/connection-store");
|
||||||
|
useConnectionStore.setState({
|
||||||
|
status: "connected",
|
||||||
|
failedSince: null,
|
||||||
|
lastHealthCheck: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ error: "Not found" }), { status: 404 }),
|
||||||
|
);
|
||||||
|
await expect(apiGet("/v1/test")).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(useConnectionStore.getState().status).toBe("connected");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// -- Cross-cutting: token is read fresh on every call --------------------
|
// -- Cross-cutting: token is read fresh on every call --------------------
|
||||||
|
|
||||||
describe("token freshness", () => {
|
describe("token freshness", () => {
|
||||||
@@ -765,3 +799,30 @@ describe("API lib", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// useAuth security
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
describe("useAuth security — network error", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT grant admin when API is unreachable", async () => {
|
||||||
|
fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||||
|
|
||||||
|
const { renderHook, act } = await import("@testing-library/react");
|
||||||
|
const { useAuth } = await import("@/hooks/use-auth");
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
|
||||||
|
// Flush the async checkAuth() call
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
// loading stays true — setState was never called
|
||||||
|
expect(result.current.loading).toBe(true);
|
||||||
|
expect(result.current.isAuthenticated).toBe(false);
|
||||||
|
expect(result.current.role).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { defineConfig } from "vitest/config";
|
|||||||
// Resolve api-workspace packages that pnpm only exposes under apps/api/node_modules.
|
// Resolve api-workspace packages that pnpm only exposes under apps/api/node_modules.
|
||||||
const apiNodeModules = path.resolve(__dirname, "apps/api/node_modules");
|
const apiNodeModules = path.resolve(__dirname, "apps/api/node_modules");
|
||||||
|
|
||||||
|
// Resolve web-workspace packages that pnpm only exposes under apps/web/node_modules.
|
||||||
|
const webNodeModules = path.resolve(__dirname, "apps/web/node_modules");
|
||||||
|
|
||||||
// Temp dir for integration test DB + workspace (set BEFORE any app code loads)
|
// Temp dir for integration test DB + workspace (set BEFORE any app code loads)
|
||||||
const testDir = path.join(os.tmpdir(), `ashim-test-${crypto.randomUUID().slice(0, 8)}`);
|
const testDir = path.join(os.tmpdir(), `ashim-test-${crypto.randomUUID().slice(0, 8)}`);
|
||||||
|
|
||||||
@@ -88,6 +91,10 @@ export default defineConfig({
|
|||||||
jsqr: path.join(apiNodeModules, "jsqr"),
|
jsqr: path.join(apiNodeModules, "jsqr"),
|
||||||
pdfkit: path.join(apiNodeModules, "pdfkit"),
|
pdfkit: path.join(apiNodeModules, "pdfkit"),
|
||||||
sharp: path.join(apiNodeModules, "sharp"),
|
sharp: path.join(apiNodeModules, "sharp"),
|
||||||
|
// Map web-only dependencies so component tests can resolve them
|
||||||
|
// from the root vitest runner.
|
||||||
|
react: path.join(webNodeModules, "react"),
|
||||||
|
"react-dom": path.join(webNodeModules, "react-dom"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user