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:
Nico 2026-03-22 11:31:48 +01:00 committed by GitHub
parent dd1aca7e30
commit 4fec2777ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 168 additions and 41 deletions

View file

@ -53,8 +53,8 @@ const createTestQueryClient = () =>
},
});
const ConnectionConsumer = () => {
useServerEvents();
const ConnectionConsumer = ({ enabled = true }: { enabled?: boolean }) => {
useServerEvents({ enabled });
return null;
};
@ -129,4 +129,24 @@ describe("useServerEvents", () => {
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");
});
});

View file

@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { logger } from "~/client/lib/logger";
import type { FileEntry } from "../components/file-tree";
export type FetchFolderResult = {
@ -135,7 +136,7 @@ export const useFileBrowser = (props: UseFileBrowserOptions) => {
setFetchedFolders((prev) => new Set(prev).add(folderPath));
} catch (error) {
console.error("Failed to fetch folder contents:", error);
logger.error("Failed to fetch folder contents:", error);
} finally {
setLoadingFolders((prev) => {
const next = new Set(prev);
@ -190,7 +191,7 @@ export const useFileBrowser = (props: UseFileBrowserOptions) => {
});
}
} catch (error) {
console.error("Failed to load more files:", error);
logger.error("Failed to load more files:", error);
setFolderPagination((prev) => {
const next = new Map(prev);
next.set(folderPath, { ...pagination, isLoadingMore: false });

View file

@ -1,5 +1,6 @@
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef } from "react";
import { logger } from "~/client/lib/logger";
import { serverEventNames, type ServerEventPayloadMap } from "~/schemas/server-events";
type LifecycleEventPayloadMap = {
@ -64,7 +65,7 @@ const refreshQueriesForEvent = (eventName: ServerEventType) => {
void sharedState.queryClient.invalidateQueries().catch((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) => {
const data = parseEventData<"connected">(event);
console.info("[SSE] Connected to server events");
logger.info("[SSE] Connected to server events");
emit("connected", data);
});
@ -91,7 +92,7 @@ const connectEventSource = (queryClient: QueryClient) => {
for (const eventName of serverEventNames) {
eventSource.addEventListener(eventName, (event) => {
const data = parseEventData<typeof eventName>(event);
console.info(`[SSE] ${eventName}:`, data);
logger.info(`[SSE] ${eventName}:`, data);
refreshQueriesForEvent(eventName);
@ -107,7 +108,7 @@ const connectEventSource = (queryClient: QueryClient) => {
}
eventSource.onerror = (error) => {
console.error("[SSE] Connection error:", error);
logger.error("[SSE] Connection error:", error);
};
};
@ -116,7 +117,7 @@ const disconnectEventSource = () => {
return;
}
console.info("[SSE] Disconnecting from server events");
logger.info("[SSE] Disconnecting from server events");
sharedState.eventSource.close();
sharedState.eventSource = null;
sharedState.queryClient = null;
@ -159,12 +160,16 @@ const addSharedEventListener = <T extends ServerEventType>(
* Hook to listen to Server-Sent Events (SSE) from the backend
* Automatically handles cache invalidation for backup and volume events
*/
export function useServerEvents() {
export function useServerEvents({ enabled = true }: { enabled?: boolean } = {}) {
const queryClient = useQueryClient();
const addEventListener = useCallback(addSharedEventListener, []);
const hasMountedRef = useRef(false);
useEffect(() => {
if (!enabled) {
return;
}
connectEventSource(queryClient);
if (!hasMountedRef.current) {
sharedState.subscribers += 1;
@ -182,7 +187,7 @@ export function useServerEvents() {
disconnectEventSource();
}
};
}, [queryClient]);
}, [enabled, queryClient]);
return { addEventListener };
}

View file

@ -2,6 +2,8 @@ import { formatDistanceToNow, isValid } from "date-fns";
type DateInput = Date | string | number | null | undefined;
const getLocales = () => (typeof navigator !== "undefined" ? navigator.languages : undefined);
function formatValidDate(date: DateInput, formatter: (date: Date) => string): string {
if (!date) return "Never";
@ -14,7 +16,7 @@ function formatValidDate(date: DateInput, formatter: (date: Date) => string): st
// 1/10/2026, 2:30 PM
export function formatDateTime(date: DateInput): string {
return formatValidDate(date, (validDate) =>
Intl.DateTimeFormat(navigator.languages, {
Intl.DateTimeFormat(getLocales(), {
month: "numeric",
day: "numeric",
year: "numeric",
@ -27,7 +29,7 @@ export function formatDateTime(date: DateInput): string {
// Jan 10, 2026
export function formatDateWithMonth(date: DateInput): string {
return formatValidDate(date, (validDate) =>
Intl.DateTimeFormat(navigator.languages, {
Intl.DateTimeFormat(getLocales(), {
month: "short",
day: "numeric",
year: "numeric",
@ -38,7 +40,7 @@ export function formatDateWithMonth(date: DateInput): string {
// 1/10/2026
export function formatDate(date: DateInput): string {
return formatValidDate(date, (validDate) =>
Intl.DateTimeFormat(navigator.languages, {
Intl.DateTimeFormat(getLocales(), {
month: "numeric",
day: "numeric",
year: "numeric",
@ -49,7 +51,7 @@ export function formatDate(date: DateInput): string {
// 1/10
export function formatShortDate(date: DateInput): string {
return formatValidDate(date, (validDate) =>
Intl.DateTimeFormat(navigator.languages, {
Intl.DateTimeFormat(getLocales(), {
month: "numeric",
day: "numeric",
}).format(validDate),
@ -59,7 +61,7 @@ export function formatShortDate(date: DateInput): string {
// 1/10, 2:30 PM
export function formatShortDateTime(date: DateInput): string {
return formatValidDate(date, (validDate) =>
Intl.DateTimeFormat(navigator.languages, {
Intl.DateTimeFormat(getLocales(), {
month: "numeric",
day: "numeric",
hour: "numeric",
@ -71,7 +73,7 @@ export function formatShortDateTime(date: DateInput): string {
// 2:30 PM
export function formatTime(date: DateInput): string {
return formatValidDate(date, (validDate) =>
Intl.DateTimeFormat(navigator.languages, {
Intl.DateTimeFormat(getLocales(), {
hour: "numeric",
minute: "numeric",
}).format(validDate),

7
app/client/lib/logger.ts Normal file
View 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),
};

View file

@ -9,6 +9,7 @@ import { Input } from "~/client/components/ui/input";
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
import { ResetPasswordDialog } from "../components/reset-password-dialog";
import { useNavigate } from "@tanstack/react-router";
@ -65,7 +66,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
});
if (error) {
console.error(error);
logger.error(error);
toast.error("Login failed", { description: error.message });
return;
}
@ -103,7 +104,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
});
if (error) {
console.error(error);
logger.error(error);
toast.error("Verification failed", { description: error.message });
setTotpCode("");
return;

View file

@ -15,6 +15,7 @@ import { AuthLayout } from "~/client/components/auth-layout";
import { Input } from "~/client/components/ui/input";
import { Button } from "~/client/components/ui/button";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username";
@ -78,7 +79,7 @@ export function OnboardingPage() {
toast.success("Admin user created successfully!");
void navigate({ to: "/download-recovery-key" });
} else if (error) {
console.error(error);
logger.error(error);
const errorMessage = error.message ?? "Unknown error";
toast.error("Failed to create admin user", { description: errorMessage });
}

View file

@ -13,6 +13,7 @@ import {
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
type BackupCodesDialogProps = {
open: boolean;
@ -45,7 +46,7 @@ export const BackupCodesDialog = ({ open, onOpenChange }: BackupCodesDialogProps
});
if (error) {
console.error(error);
logger.error(error);
toast.error("Failed to generate backup codes", { description: error.message });
return;
}

View file

@ -12,6 +12,7 @@ import {
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
type TwoFactorDisableDialogProps = {
open: boolean;
@ -44,7 +45,7 @@ export const TwoFactorDisableDialog = ({ open, onOpenChange, onSuccess }: TwoFac
});
if (error) {
console.error(error);
logger.error(error);
toast.error("Failed to disable 2FA", { description: error.message });
return;
}

View file

@ -14,6 +14,7 @@ import { Input } from "~/client/components/ui/input";
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
type TwoFactorSetupDialogProps = {
open: boolean;
@ -52,7 +53,7 @@ export const TwoFactorSetupDialog = ({ open, onOpenChange, onSuccess }: TwoFacto
});
if (error) {
console.error(error);
logger.error(error);
toast.error("Failed to enable 2FA", { description: error.message });
return;
}
@ -81,7 +82,7 @@ export const TwoFactorSetupDialog = ({ open, onOpenChange, onSuccess }: TwoFacto
});
if (error) {
console.error(error);
logger.error(error);
toast.error("Verification failed", { description: error.message });
setVerificationCode("");
return;

View file

@ -19,6 +19,7 @@ import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { type AppContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section";
import { useNavigate, useSearch } from "@tanstack/react-router";
@ -55,7 +56,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
void navigate({ to: "/login", replace: true });
},
onError: ({ error }) => {
console.error(error);
logger.error(error);
toast.error("Logout failed", { description: error.message });
},
},

View file

@ -2,6 +2,7 @@ import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
export function SsoLoginSection() {
@ -25,7 +26,7 @@ export function SsoLoginSection() {
window.location.href = data.url;
},
onError: (error) => {
console.error(error);
logger.error(error);
toast.error("SSO Login failed", { description: error.message });
},
});

7
app/lib/auth-routes.ts Normal file
View 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;
}

View file

@ -3,14 +3,7 @@ import { redirect } from "@tanstack/react-router";
import { auth } from "~/server/lib/auth";
import { getRequestHeaders } from "@tanstack/react-start/server";
import { authService } from "~/server/modules/auth/auth.service";
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;
}
import { isAuthRoute } from "~/lib/auth-routes";
export const authMiddleware = createMiddleware().server(async ({ next, request }) => {
const headers = getRequestHeaders();

View file

@ -2,6 +2,7 @@ import { createRouter } from "@tanstack/react-router";
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
import { routeTree } from "./routeTree.gen";
import { MutationCache, QueryClient } from "@tanstack/react-query";
import { logger } from "./client/lib/logger";
import { client } from "./client/api-client/client.gen";
import type { BreadcrumbItemData } from "./client/components/app-breadcrumb";
@ -22,7 +23,7 @@ export function getRouter() {
void queryClient.invalidateQueries();
},
onError: (error) => {
console.error("Mutation error:", error);
logger.error("Mutation error:", error);
void queryClient.invalidateQueries();
},
}),

View file

@ -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 { apiClientMiddleware } from "~/middleware/api-client";
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 { createServerFn } from "@tanstack/react-start";
import { getCookie } from "@tanstack/react-start/server";
import { isAuthRoute } from "~/lib/auth-routes";
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
const themeCookie = getCookie(THEME_COOKIE_NAME);
@ -46,9 +47,10 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
errorComponent: (e) => <div>{e.error.message}</div>,
});
function RootLayout() {
export function RootLayout() {
const { theme } = Route.useLoaderData();
useServerEvents();
const pathname = useRouterState({ select: (state) => state.location.pathname });
useServerEvents({ enabled: !isAuthRoute(pathname) });
useEffect(() => {
document.body.setAttribute("data-app-ready", "true");
return () => {

View file

@ -1,5 +1,5 @@
import fs from "fs";
import { test, expect } from "@playwright/test";
import { test, expect } from "./test";
import { resetDatabase } from "./helpers/db";
import path from "node:path";
import { gotoAndWaitForAppReady } from "./helpers/page";

View file

@ -1,7 +1,8 @@
import fs from "node:fs";
import { randomUUID } from "node:crypto";
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";
const testDataPath = path.join(process.cwd(), "playwright", "temp");

View file

@ -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";
const dexOrigin = process.env.E2E_DEX_ORIGIN ?? "http://dex:5557";
@ -205,6 +207,7 @@ async function withOidcLoginAttempt(
origins: [],
},
});
const browserErrorTracker = trackBrowserErrors(context);
const page = await context.newPage();
try {
@ -221,6 +224,7 @@ async function withOidcLoginAttempt(
}
await assertions(page);
browserErrorTracker.assertNoBrowserErrors();
} finally {
await context.close().catch(() => undefined);
}

View 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
View 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 };