Merge branch 'feat/graceful-degradation' into feat/on-demand-ai-features

This commit is contained in:
ashim-hq
2026-04-21 09:24:37 +08:00
14 changed files with 801 additions and 125 deletions
+104 -44
View File
@@ -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 { Toaster } from "sonner";
import { ConnectionBanner } from "./components/common/connection-banner";
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
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
// downloaded when the user navigates there, shrinking the main bundle.
const AutomatePage = lazy(() =>
// Lazy-load all pages with automatic retry so chunk failures from
// deployments are recovered transparently instead of white-screening.
const AutomatePage = lazyWithRetry(() =>
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 })),
);
const FilesPage = lazy(() => import("./pages/files-page").then((m) => ({ default: m.FilesPage })));
const FullscreenGridPage = lazy(() =>
const FilesPage = lazyWithRetry(() =>
import("./pages/files-page").then((m) => ({ default: m.FilesPage })),
);
const FullscreenGridPage = lazyWithRetry(() =>
import("./pages/fullscreen-grid-page").then((m) => ({ default: m.FullscreenGridPage })),
);
const HomePage = lazy(() => import("./pages/home-page").then((m) => ({ default: m.HomePage })));
const LoginPage = lazy(() => import("./pages/login-page").then((m) => ({ default: m.LoginPage })));
const PrivacyPolicyPage = lazy(() =>
const HomePage = lazyWithRetry(() =>
import("./pages/home-page").then((m) => ({ default: m.HomePage })),
);
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 })),
);
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<
{ children: ReactNode },
{ hasError: boolean; error: Error | null }
{ hasError: boolean; error: Error | null; isChunkError: boolean }
> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
this.state = { hasError: false, error: null, isChunkError: false };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
return { hasError: true, error, isChunkError: isChunkError(error) };
}
componentDidCatch(error: Error, info: ErrorInfo) {
@@ -42,6 +53,43 @@ class ErrorBoundary extends Component<
render() {
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 (
<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">
@@ -52,7 +100,7 @@ class ErrorBoundary extends Component<
<button
type="button"
onClick={() => {
this.setState({ hasError: false, error: null });
this.setState({ hasError: false, error: null, isChunkError: false });
window.location.href = "/";
}}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium"
@@ -112,36 +160,48 @@ function PageLoader() {
);
}
function ConnectionMonitor() {
useConnectionMonitor();
return null;
}
export function App() {
return (
<ErrorBoundary>
<Toaster position="bottom-right" />
<BrowserRouter>
<KeyboardShortcutProvider>
<AuthGuard>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/change-password" element={<ChangePasswordPage />} />
<Route path="/automate" element={<AutomatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/fullscreen" element={<FullscreenGridPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
{/* Redirects: old color tools consolidated into adjust-colors */}
<Route
path="/brightness-contrast"
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-effects" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/:toolId" element={<ToolPage />} />
<Route path="/" element={<HomePage />} />
</Routes>
</Suspense>
</AuthGuard>
</KeyboardShortcutProvider>
</BrowserRouter>
</ErrorBoundary>
<>
<ConnectionMonitor />
<ConnectionBanner />
<ErrorBoundary>
<Toaster position="bottom-right" />
<BrowserRouter>
<KeyboardShortcutProvider>
<AuthGuard>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/change-password" element={<ChangePasswordPage />} />
<Route path="/automate" element={<AutomatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/fullscreen" element={<FullscreenGridPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
{/* Redirects: old color tools consolidated into adjust-colors */}
<Route
path="/brightness-contrast"
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-effects" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/:toolId" element={<ToolPage />} />
<Route path="/" element={<HomePage />} />
</Routes>
</Suspense>
</AuthGuard>
</KeyboardShortcutProvider>
</BrowserRouter>
</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>
);
}
+15 -2
View File
@@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
import { useMobile } from "@/hooks/use-mobile";
import { apiGet } from "@/lib/api";
import { cn } from "@/lib/utils";
import { useConnectionStore } from "@/stores/connection-store";
import { Dropzone } from "../common/dropzone";
import { GemLogo } from "../common/gem-logo";
import { HelpDialog } from "../help/help-dialog";
@@ -25,6 +26,8 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const isMobile = useMobile();
const [customLogo, setCustomLogo] = useState(false);
const connectionStatus = useConnectionStore((s) => s.status);
const bannerVisible = connectionStatus !== "connected";
useEffect(() => {
apiGet<{ settings: Record<string, string> }>("/v1/settings")
@@ -33,7 +36,12 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
}, []);
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 */}
{!isMobile && (
<Sidebar
@@ -92,7 +100,12 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
{/* Mobile top bar */}
{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
type="button"
onClick={() => setMobileSidebarOpen(true)}
+52 -43
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { formatHeaders } from "@/lib/api";
import { useConnectionStore } from "@/stores/connection-store";
interface AuthState {
loading: boolean;
@@ -36,35 +37,37 @@ export function useAuth() {
});
useEffect(() => {
let cancelled = false;
async function checkAuth() {
try {
// Check if auth is enabled
const configRes = await fetch("/api/v1/config/auth");
const config = await configRes.json();
if (!config.authEnabled) {
setState({
loading: false,
authEnabled: false,
isAuthenticated: true,
mustChangePassword: false,
role: "admin",
permissions: ALL_PERMISSIONS,
});
if (!cancelled)
setState({
loading: false,
authEnabled: false,
isAuthenticated: true,
mustChangePassword: false,
role: "admin",
permissions: ALL_PERMISSIONS,
});
return;
}
// Auth is enabled — check if we have a valid session
const token = localStorage.getItem("ashim-token");
if (!token) {
setState({
loading: false,
authEnabled: true,
isAuthenticated: false,
mustChangePassword: false,
role: null,
permissions: [],
});
if (!cancelled)
setState({
loading: false,
authEnabled: true,
isAuthenticated: false,
mustChangePassword: false,
role: null,
permissions: [],
});
return;
}
@@ -75,39 +78,45 @@ export function useAuth() {
if (sessionRes.ok) {
const session = await sessionRes.json();
const mustChange = session.user?.mustChangePassword === true;
setState({
loading: false,
authEnabled: true,
isAuthenticated: true,
mustChangePassword: mustChange,
role: session.user?.role ?? null,
permissions: session.user?.permissions ?? [],
});
if (!cancelled)
setState({
loading: false,
authEnabled: true,
isAuthenticated: true,
mustChangePassword: mustChange,
role: session.user?.role ?? null,
permissions: session.user?.permissions ?? [],
});
} else {
localStorage.removeItem("ashim-token");
setState({
loading: false,
authEnabled: true,
isAuthenticated: false,
mustChangePassword: false,
role: null,
permissions: [],
});
if (!cancelled)
setState({
loading: false,
authEnabled: true,
isAuthenticated: false,
mustChangePassword: false,
role: null,
permissions: [],
});
}
} catch {
// Can't reach API — assume no auth needed (dev mode)
setState({
loading: false,
authEnabled: false,
isAuthenticated: true,
mustChangePassword: false,
role: "admin",
permissions: ALL_PERMISSIONS,
});
// API unreachable — stay in loading state.
// ConnectionBanner explains the outage. AuthGuard shows spinner.
}
}
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);
@@ -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();
};
}, []);
}
+1 -1
View File
@@ -261,7 +261,7 @@ export function useToolProcessor(toolId: string) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setError("Network error - check your connection");
setError("Processing was interrupted \u2014 retry when reconnected");
setProcessing(false);
setProgress(IDLE_PROGRESS);
};
+101 -35
View File
@@ -1,3 +1,5 @@
import { useConnectionStore } from "@/stores/connection-store";
const API_BASE = "/api";
export interface FeatureNotInstalledError {
@@ -74,9 +76,17 @@ async function throwWithMessage(res: Response): Promise<never> {
}
export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: formatHeaders(),
});
let res: Response;
try {
res = await fetch(`${API_BASE}${path}`, {
headers: formatHeaders(),
});
} catch (error) {
if (error instanceof TypeError) {
useConnectionStore.getState().setDisconnected();
}
throw error;
}
if (!res.ok) await throwWithMessage(res);
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> {
const headers =
body !== undefined ? formatHeaders({ "Content-Type": "application/json" }) : formatHeaders();
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
let res: Response;
try {
res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch (error) {
if (error instanceof TypeError) {
useConnectionStore.getState().setDisconnected();
}
throw error;
}
if (!res.ok) await throwWithMessage(res);
return res.json();
}
export async function apiPut<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: "PUT",
headers: formatHeaders({ "Content-Type": "application/json" }),
body: body ? JSON.stringify(body) : undefined,
});
let res: Response;
try {
res = await fetch(`${API_BASE}${path}`, {
method: "PUT",
headers: formatHeaders({ "Content-Type": "application/json" }),
body: body ? JSON.stringify(body) : undefined,
});
} catch (error) {
if (error instanceof TypeError) {
useConnectionStore.getState().setDisconnected();
}
throw error;
}
if (!res.ok) await throwWithMessage(res);
return res.json();
}
export async function apiDelete<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: "DELETE",
headers: formatHeaders(),
});
let res: Response;
try {
res = await fetch(`${API_BASE}${path}`, {
method: "DELETE",
headers: formatHeaders(),
});
} catch (error) {
if (error instanceof TypeError) {
useConnectionStore.getState().setDisconnected();
}
throw error;
}
if (!res.ok) await throwWithMessage(res);
return res.json();
}
@@ -128,11 +162,19 @@ export async function apiUpload(files: File[]): Promise<{
}> {
const formData = new FormData();
for (const f of files) formData.append("files", f);
const res = await fetch("/api/v1/upload", {
method: "POST",
headers: formatHeaders(),
body: formData,
});
let res: Response;
try {
res = await fetch("/api/v1/upload", {
method: "POST",
headers: formatHeaders(),
body: formData,
});
} catch (error) {
if (error instanceof TypeError) {
useConnectionStore.getState().setDisconnected();
}
throw error;
}
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
return res.json();
}
@@ -190,21 +232,37 @@ export async function apiUploadUserFiles(
): Promise<{ files: Array<{ id: string; originalName: string; size: number; version: number }> }> {
const formData = new FormData();
for (const f of files) formData.append("files", f);
const res = await fetch("/api/v1/files/upload", {
method: "POST",
headers: formatHeaders(),
body: formData,
});
let res: Response;
try {
res = await fetch("/api/v1/files/upload", {
method: "POST",
headers: formatHeaders(),
body: formData,
});
} catch (error) {
if (error instanceof TypeError) {
useConnectionStore.getState().setDisconnected();
}
throw error;
}
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
return res.json();
}
export async function apiDeleteUserFiles(ids: string[]): Promise<{ deleted: number }> {
const res = await fetch("/api/v1/files", {
method: "DELETE",
headers: formatHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ ids }),
});
let res: Response;
try {
res = await fetch("/api/v1/files", {
method: "DELETE",
headers: formatHeaders({ "Content-Type": "application/json" }),
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}`);
return res.json();
}
@@ -218,9 +276,17 @@ export function getFileDownloadUrl(id: string): string {
}
export async function apiDownloadBlob(jobId: string, filename: string): Promise<Blob> {
const res = await fetch(getDownloadUrl(jobId, filename), {
headers: formatHeaders(),
});
let res: Response;
try {
res = await fetch(getDownloadUrl(jobId, filename), {
headers: formatHeaders(),
});
} catch (error) {
if (error instanceof TypeError) {
useConnectionStore.getState().setDisconnected();
}
throw error;
}
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
return res.blob();
}
+41
View File
@@ -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));
}
+90
View File
@@ -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(),
]);
},
}));
+49
View File
@@ -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)),
};
}
+130
View File
@@ -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();
});
});
+55
View File
@@ -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);
});
});
+61
View File
@@ -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 --------------------
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();
});
});
+7
View File
@@ -6,6 +6,9 @@ import { defineConfig } from "vitest/config";
// Resolve api-workspace packages that pnpm only exposes under 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)
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"),
pdfkit: path.join(apiNodeModules, "pdfkit"),
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"),
},
},
});