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 = () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
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 { 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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
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 { 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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
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