test(e2e): fail in unexpected console.error (#696)
* test(e2e): fail in unexpected console.error * fix(datetime): graceful fallback during SSR when navigator is undefined
This commit is contained in:
parent
dd1aca7e30
commit
4fec2777ce
21 changed files with 168 additions and 41 deletions
|
|
@ -53,8 +53,8 @@ const createTestQueryClient = () =>
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const ConnectionConsumer = () => {
|
const ConnectionConsumer = ({ enabled = true }: { enabled?: boolean }) => {
|
||||||
useServerEvents();
|
useServerEvents({ enabled });
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -129,4 +129,24 @@ describe("useServerEvents", () => {
|
||||||
|
|
||||||
expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1);
|
expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("waits to subscribe until enabled", () => {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
const view = render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ConnectionConsumer enabled={false} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(MockEventSource.instances).toHaveLength(0);
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ConnectionConsumer />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(MockEventSource.instances).toHaveLength(1);
|
||||||
|
expect(MockEventSource.instances[0]?.url).toBe("/api/v1/events");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
import type { FileEntry } from "../components/file-tree";
|
import type { FileEntry } from "../components/file-tree";
|
||||||
|
|
||||||
export type FetchFolderResult = {
|
export type FetchFolderResult = {
|
||||||
|
|
@ -135,7 +136,7 @@ export const useFileBrowser = (props: UseFileBrowserOptions) => {
|
||||||
|
|
||||||
setFetchedFolders((prev) => new Set(prev).add(folderPath));
|
setFetchedFolders((prev) => new Set(prev).add(folderPath));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch folder contents:", error);
|
logger.error("Failed to fetch folder contents:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingFolders((prev) => {
|
setLoadingFolders((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
|
|
@ -190,7 +191,7 @@ export const useFileBrowser = (props: UseFileBrowserOptions) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load more files:", error);
|
logger.error("Failed to load more files:", error);
|
||||||
setFolderPagination((prev) => {
|
setFolderPagination((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(folderPath, { ...pagination, isLoadingMore: false });
|
next.set(folderPath, { ...pagination, isLoadingMore: false });
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
|
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
import { serverEventNames, type ServerEventPayloadMap } from "~/schemas/server-events";
|
import { serverEventNames, type ServerEventPayloadMap } from "~/schemas/server-events";
|
||||||
|
|
||||||
type LifecycleEventPayloadMap = {
|
type LifecycleEventPayloadMap = {
|
||||||
|
|
@ -64,7 +65,7 @@ const refreshQueriesForEvent = (eventName: ServerEventType) => {
|
||||||
|
|
||||||
void sharedState.queryClient.invalidateQueries().catch((error) => {
|
void sharedState.queryClient.invalidateQueries().catch((error) => {
|
||||||
if (!isAbortError(error)) {
|
if (!isAbortError(error)) {
|
||||||
console.error(`[SSE] Failed to refresh queries after ${eventName}:`, error);
|
logger.error(`[SSE] Failed to refresh queries after ${eventName}:`, error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -80,7 +81,7 @@ const connectEventSource = (queryClient: QueryClient) => {
|
||||||
|
|
||||||
eventSource.addEventListener("connected", (event) => {
|
eventSource.addEventListener("connected", (event) => {
|
||||||
const data = parseEventData<"connected">(event);
|
const data = parseEventData<"connected">(event);
|
||||||
console.info("[SSE] Connected to server events");
|
logger.info("[SSE] Connected to server events");
|
||||||
emit("connected", data);
|
emit("connected", data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -91,7 +92,7 @@ const connectEventSource = (queryClient: QueryClient) => {
|
||||||
for (const eventName of serverEventNames) {
|
for (const eventName of serverEventNames) {
|
||||||
eventSource.addEventListener(eventName, (event) => {
|
eventSource.addEventListener(eventName, (event) => {
|
||||||
const data = parseEventData<typeof eventName>(event);
|
const data = parseEventData<typeof eventName>(event);
|
||||||
console.info(`[SSE] ${eventName}:`, data);
|
logger.info(`[SSE] ${eventName}:`, data);
|
||||||
|
|
||||||
refreshQueriesForEvent(eventName);
|
refreshQueriesForEvent(eventName);
|
||||||
|
|
||||||
|
|
@ -107,7 +108,7 @@ const connectEventSource = (queryClient: QueryClient) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
console.error("[SSE] Connection error:", error);
|
logger.error("[SSE] Connection error:", error);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -116,7 +117,7 @@ const disconnectEventSource = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info("[SSE] Disconnecting from server events");
|
logger.info("[SSE] Disconnecting from server events");
|
||||||
sharedState.eventSource.close();
|
sharedState.eventSource.close();
|
||||||
sharedState.eventSource = null;
|
sharedState.eventSource = null;
|
||||||
sharedState.queryClient = null;
|
sharedState.queryClient = null;
|
||||||
|
|
@ -159,12 +160,16 @@ const addSharedEventListener = <T extends ServerEventType>(
|
||||||
* Hook to listen to Server-Sent Events (SSE) from the backend
|
* Hook to listen to Server-Sent Events (SSE) from the backend
|
||||||
* Automatically handles cache invalidation for backup and volume events
|
* Automatically handles cache invalidation for backup and volume events
|
||||||
*/
|
*/
|
||||||
export function useServerEvents() {
|
export function useServerEvents({ enabled = true }: { enabled?: boolean } = {}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const addEventListener = useCallback(addSharedEventListener, []);
|
const addEventListener = useCallback(addSharedEventListener, []);
|
||||||
const hasMountedRef = useRef(false);
|
const hasMountedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
connectEventSource(queryClient);
|
connectEventSource(queryClient);
|
||||||
if (!hasMountedRef.current) {
|
if (!hasMountedRef.current) {
|
||||||
sharedState.subscribers += 1;
|
sharedState.subscribers += 1;
|
||||||
|
|
@ -182,7 +187,7 @@ export function useServerEvents() {
|
||||||
disconnectEventSource();
|
disconnectEventSource();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [queryClient]);
|
}, [enabled, queryClient]);
|
||||||
|
|
||||||
return { addEventListener };
|
return { addEventListener };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { formatDistanceToNow, isValid } from "date-fns";
|
||||||
|
|
||||||
type DateInput = Date | string | number | null | undefined;
|
type DateInput = Date | string | number | null | undefined;
|
||||||
|
|
||||||
|
const getLocales = () => (typeof navigator !== "undefined" ? navigator.languages : undefined);
|
||||||
|
|
||||||
function formatValidDate(date: DateInput, formatter: (date: Date) => string): string {
|
function formatValidDate(date: DateInput, formatter: (date: Date) => string): string {
|
||||||
if (!date) return "Never";
|
if (!date) return "Never";
|
||||||
|
|
||||||
|
|
@ -14,7 +16,7 @@ function formatValidDate(date: DateInput, formatter: (date: Date) => string): st
|
||||||
// 1/10/2026, 2:30 PM
|
// 1/10/2026, 2:30 PM
|
||||||
export function formatDateTime(date: DateInput): string {
|
export function formatDateTime(date: DateInput): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(navigator.languages, {
|
Intl.DateTimeFormat(getLocales(), {
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|
@ -27,7 +29,7 @@ export function formatDateTime(date: DateInput): string {
|
||||||
// Jan 10, 2026
|
// Jan 10, 2026
|
||||||
export function formatDateWithMonth(date: DateInput): string {
|
export function formatDateWithMonth(date: DateInput): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(navigator.languages, {
|
Intl.DateTimeFormat(getLocales(), {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|
@ -38,7 +40,7 @@ export function formatDateWithMonth(date: DateInput): string {
|
||||||
// 1/10/2026
|
// 1/10/2026
|
||||||
export function formatDate(date: DateInput): string {
|
export function formatDate(date: DateInput): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(navigator.languages, {
|
Intl.DateTimeFormat(getLocales(), {
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|
@ -49,7 +51,7 @@ export function formatDate(date: DateInput): string {
|
||||||
// 1/10
|
// 1/10
|
||||||
export function formatShortDate(date: DateInput): string {
|
export function formatShortDate(date: DateInput): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(navigator.languages, {
|
Intl.DateTimeFormat(getLocales(), {
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}).format(validDate),
|
}).format(validDate),
|
||||||
|
|
@ -59,7 +61,7 @@ export function formatShortDate(date: DateInput): string {
|
||||||
// 1/10, 2:30 PM
|
// 1/10, 2:30 PM
|
||||||
export function formatShortDateTime(date: DateInput): string {
|
export function formatShortDateTime(date: DateInput): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(navigator.languages, {
|
Intl.DateTimeFormat(getLocales(), {
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
|
|
@ -71,7 +73,7 @@ export function formatShortDateTime(date: DateInput): string {
|
||||||
// 2:30 PM
|
// 2:30 PM
|
||||||
export function formatTime(date: DateInput): string {
|
export function formatTime(date: DateInput): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(navigator.languages, {
|
Intl.DateTimeFormat(getLocales(), {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "numeric",
|
minute: "numeric",
|
||||||
}).format(validDate),
|
}).format(validDate),
|
||||||
|
|
|
||||||
7
app/client/lib/logger.ts
Normal file
7
app/client/lib/logger.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
const PREFIX = "[APP]";
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
error: (...args: unknown[]) => console.error(PREFIX, ...args),
|
||||||
|
warn: (...args: unknown[]) => console.warn(PREFIX, ...args),
|
||||||
|
info: (...args: unknown[]) => console.info(PREFIX, ...args),
|
||||||
|
};
|
||||||
|
|
@ -9,6 +9,7 @@ import { Input } from "~/client/components/ui/input";
|
||||||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
|
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
|
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
|
||||||
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
@ -65,7 +66,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
toast.error("Login failed", { description: error.message });
|
toast.error("Login failed", { description: error.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -103,7 +104,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
toast.error("Verification failed", { description: error.message });
|
toast.error("Verification failed", { description: error.message });
|
||||||
setTotpCode("");
|
setTotpCode("");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { AuthLayout } from "~/client/components/auth-layout";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { normalizeUsername } from "~/lib/username";
|
import { normalizeUsername } from "~/lib/username";
|
||||||
|
|
@ -78,7 +79,7 @@ export function OnboardingPage() {
|
||||||
toast.success("Admin user created successfully!");
|
toast.success("Admin user created successfully!");
|
||||||
void navigate({ to: "/download-recovery-key" });
|
void navigate({ to: "/download-recovery-key" });
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
const errorMessage = error.message ?? "Unknown error";
|
const errorMessage = error.message ?? "Unknown error";
|
||||||
toast.error("Failed to create admin user", { description: errorMessage });
|
toast.error("Failed to create admin user", { description: errorMessage });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
|
|
||||||
type BackupCodesDialogProps = {
|
type BackupCodesDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -45,7 +46,7 @@ export const BackupCodesDialog = ({ open, onOpenChange }: BackupCodesDialogProps
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
toast.error("Failed to generate backup codes", { description: error.message });
|
toast.error("Failed to generate backup codes", { description: error.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
|
|
||||||
type TwoFactorDisableDialogProps = {
|
type TwoFactorDisableDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -44,7 +45,7 @@ export const TwoFactorDisableDialog = ({ open, onOpenChange, onSuccess }: TwoFac
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
toast.error("Failed to disable 2FA", { description: error.message });
|
toast.error("Failed to disable 2FA", { description: error.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { Input } from "~/client/components/ui/input";
|
||||||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
|
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
|
|
||||||
type TwoFactorSetupDialogProps = {
|
type TwoFactorSetupDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -52,7 +53,7 @@ export const TwoFactorSetupDialog = ({ open, onOpenChange, onSuccess }: TwoFacto
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
toast.error("Failed to enable 2FA", { description: error.message });
|
toast.error("Failed to enable 2FA", { description: error.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +82,7 @@ export const TwoFactorSetupDialog = ({ open, onOpenChange, onSuccess }: TwoFacto
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
toast.error("Verification failed", { description: error.message });
|
toast.error("Verification failed", { description: error.message });
|
||||||
setVerificationCode("");
|
setVerificationCode("");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { Input } from "~/client/components/ui/input";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
import { type AppContext } from "~/context";
|
import { type AppContext } from "~/context";
|
||||||
import { TwoFactorSection } from "../components/two-factor-section";
|
import { TwoFactorSection } from "../components/two-factor-section";
|
||||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
|
|
@ -55,7 +56,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
void navigate({ to: "/login", replace: true });
|
void navigate({ to: "/login", replace: true });
|
||||||
},
|
},
|
||||||
onError: ({ error }) => {
|
onError: ({ error }) => {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
toast.error("Logout failed", { description: error.message });
|
toast.error("Logout failed", { description: error.message });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
|
||||||
export function SsoLoginSection() {
|
export function SsoLoginSection() {
|
||||||
|
|
@ -25,7 +26,7 @@ export function SsoLoginSection() {
|
||||||
window.location.href = data.url;
|
window.location.href = data.url;
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
toast.error("SSO Login failed", { description: error.message });
|
toast.error("SSO Login failed", { description: error.message });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
7
app/lib/auth-routes.ts
Normal file
7
app/lib/auth-routes.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export function isAuthRoute(pathname: string) {
|
||||||
|
if (pathname === "/onboarding") return true;
|
||||||
|
if (pathname === "/login") return true;
|
||||||
|
if (pathname.match(/^\/login\/[^/]+$/)) return true;
|
||||||
|
if (pathname.match(/^\/login\/[^/]+\/error$/)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
@ -3,14 +3,7 @@ import { redirect } from "@tanstack/react-router";
|
||||||
import { auth } from "~/server/lib/auth";
|
import { auth } from "~/server/lib/auth";
|
||||||
import { getRequestHeaders } from "@tanstack/react-start/server";
|
import { getRequestHeaders } from "@tanstack/react-start/server";
|
||||||
import { authService } from "~/server/modules/auth/auth.service";
|
import { authService } from "~/server/modules/auth/auth.service";
|
||||||
|
import { isAuthRoute } from "~/lib/auth-routes";
|
||||||
function isAuthRoute(pathname: string): boolean {
|
|
||||||
if (pathname === "/onboarding") return true;
|
|
||||||
if (pathname === "/login") return true;
|
|
||||||
if (pathname.match(/^\/login\/[^/]+$/)) return true;
|
|
||||||
if (pathname.match(/^\/login\/[^/]+\/error$/)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authMiddleware = createMiddleware().server(async ({ next, request }) => {
|
export const authMiddleware = createMiddleware().server(async ({ next, request }) => {
|
||||||
const headers = getRequestHeaders();
|
const headers = getRequestHeaders();
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { createRouter } from "@tanstack/react-router";
|
||||||
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
import { MutationCache, QueryClient } from "@tanstack/react-query";
|
import { MutationCache, QueryClient } from "@tanstack/react-query";
|
||||||
|
import { logger } from "./client/lib/logger";
|
||||||
import { client } from "./client/api-client/client.gen";
|
import { client } from "./client/api-client/client.gen";
|
||||||
import type { BreadcrumbItemData } from "./client/components/app-breadcrumb";
|
import type { BreadcrumbItemData } from "./client/components/app-breadcrumb";
|
||||||
|
|
||||||
|
|
@ -22,7 +23,7 @@ export function getRouter() {
|
||||||
void queryClient.invalidateQueries();
|
void queryClient.invalidateQueries();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Mutation error:", error);
|
logger.error("Mutation error:", error);
|
||||||
void queryClient.invalidateQueries();
|
void queryClient.invalidateQueries();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Outlet, HeadContent, Scripts, createRootRouteWithContext } from "@tanstack/react-router";
|
import { Outlet, HeadContent, Scripts, createRootRouteWithContext, useRouterState } from "@tanstack/react-router";
|
||||||
import appCss from "../app.css?url";
|
import appCss from "../app.css?url";
|
||||||
import { apiClientMiddleware } from "~/middleware/api-client";
|
import { apiClientMiddleware } from "~/middleware/api-client";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
@ -9,6 +9,7 @@ import { useEffect } from "react";
|
||||||
import { ThemeProvider, THEME_COOKIE_NAME } from "~/client/components/theme-provider";
|
import { ThemeProvider, THEME_COOKIE_NAME } from "~/client/components/theme-provider";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getCookie } from "@tanstack/react-start/server";
|
import { getCookie } from "@tanstack/react-start/server";
|
||||||
|
import { isAuthRoute } from "~/lib/auth-routes";
|
||||||
|
|
||||||
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
|
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
const themeCookie = getCookie(THEME_COOKIE_NAME);
|
const themeCookie = getCookie(THEME_COOKIE_NAME);
|
||||||
|
|
@ -46,9 +47,10 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
|
||||||
errorComponent: (e) => <div>{e.error.message}</div>,
|
errorComponent: (e) => <div>{e.error.message}</div>,
|
||||||
});
|
});
|
||||||
|
|
||||||
function RootLayout() {
|
export function RootLayout() {
|
||||||
const { theme } = Route.useLoaderData();
|
const { theme } = Route.useLoaderData();
|
||||||
useServerEvents();
|
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||||
|
useServerEvents({ enabled: !isAuthRoute(pathname) });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.setAttribute("data-app-ready", "true");
|
document.body.setAttribute("data-app-ready", "true");
|
||||||
return () => {
|
return () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "./test";
|
||||||
import { resetDatabase } from "./helpers/db";
|
import { resetDatabase } from "./helpers/db";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { gotoAndWaitForAppReady } from "./helpers/page";
|
import { gotoAndWaitForAppReady } from "./helpers/page";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { expect, test, type Page, type TestInfo } from "@playwright/test";
|
import { type Page, type TestInfo } from "@playwright/test";
|
||||||
|
import { expect, test } from "./test";
|
||||||
import { gotoAndWaitForAppReady } from "./helpers/page";
|
import { gotoAndWaitForAppReady } from "./helpers/page";
|
||||||
|
|
||||||
const testDataPath = path.join(process.cwd(), "playwright", "temp");
|
const testDataPath = path.join(process.cwd(), "playwright", "temp");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { expect, test, type Browser, type Page } from "@playwright/test";
|
import { type Browser, type Page } from "@playwright/test";
|
||||||
|
import { expect, test } from "./test";
|
||||||
|
import { trackBrowserErrors } from "./helpers/browser-errors";
|
||||||
import { gotoAndWaitForAppReady, waitForAppReady } from "./helpers/page";
|
import { gotoAndWaitForAppReady, waitForAppReady } from "./helpers/page";
|
||||||
|
|
||||||
const dexOrigin = process.env.E2E_DEX_ORIGIN ?? "http://dex:5557";
|
const dexOrigin = process.env.E2E_DEX_ORIGIN ?? "http://dex:5557";
|
||||||
|
|
@ -205,6 +207,7 @@ async function withOidcLoginAttempt(
|
||||||
origins: [],
|
origins: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const browserErrorTracker = trackBrowserErrors(context);
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -221,6 +224,7 @@ async function withOidcLoginAttempt(
|
||||||
}
|
}
|
||||||
|
|
||||||
await assertions(page);
|
await assertions(page);
|
||||||
|
browserErrorTracker.assertNoBrowserErrors();
|
||||||
} finally {
|
} finally {
|
||||||
await context.close().catch(() => undefined);
|
await context.close().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
e2e/helpers/browser-errors.ts
Normal file
63
e2e/helpers/browser-errors.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import type { BrowserContext, ConsoleMessage, Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const IGNORED_PATTERNS: RegExp[] = [
|
||||||
|
/^\[APP\]/,
|
||||||
|
/Failed to load resource: the server responded with a status of 4\d{2}/,
|
||||||
|
];
|
||||||
|
|
||||||
|
const isIgnoredError = (text: string) => IGNORED_PATTERNS.some((pattern) => pattern.test(text));
|
||||||
|
|
||||||
|
const formatConsoleMessage = (message: ConsoleMessage) => {
|
||||||
|
const location = message.location();
|
||||||
|
const hasLocation = !!location.url;
|
||||||
|
const formattedLocation = hasLocation
|
||||||
|
? ` (${location.url}:${location.lineNumber + 1}:${location.columnNumber + 1})`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `console.error${formattedLocation}\n${message.text()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const trackBrowserErrors = (context: BrowserContext) => {
|
||||||
|
const browserErrors: string[] = [];
|
||||||
|
const trackedPages = new WeakSet<Page>();
|
||||||
|
|
||||||
|
const trackPage = (page: Page) => {
|
||||||
|
if (trackedPages.has(page)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackedPages.add(page);
|
||||||
|
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() !== "error") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isIgnoredError(message.text())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
browserErrors.push(formatConsoleMessage(message));
|
||||||
|
});
|
||||||
|
|
||||||
|
page.on("pageerror", (error) => {
|
||||||
|
browserErrors.push(`pageerror\n${error.stack ?? error.message}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const page of context.pages()) {
|
||||||
|
trackPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.on("page", trackPage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
assertNoBrowserErrors() {
|
||||||
|
if (browserErrors.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Browser console errors detected:\n\n${browserErrors.join("\n\n")}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
14
e2e/test.ts
Normal file
14
e2e/test.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { expect, test as base } from "@playwright/test";
|
||||||
|
import { trackBrowserErrors } from "./helpers/browser-errors";
|
||||||
|
|
||||||
|
export const test = base.extend({
|
||||||
|
context: async ({ context }, use) => {
|
||||||
|
const browserErrorTracker = trackBrowserErrors(context);
|
||||||
|
|
||||||
|
await use(context);
|
||||||
|
|
||||||
|
browserErrorTracker.assertNoBrowserErrors();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect };
|
||||||
Loading…
Reference in a new issue