diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 18de0269..a8167be0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -52,6 +52,7 @@ jobs: echo "ZEROBYTE_DATABASE_URL=./data/zerobyte.db" >> .env.local echo "BASE_URL=http://localhost:4096" >> .env.local echo "E2E_DEX_ORIGIN=http://dex:5557" >> .env.local + echo "TZ=Europe/Zurich" >> .env.local - name: Start zerobyte-e2e service run: bun run start:e2e -- -d diff --git a/app/client.tsx b/app/client.tsx new file mode 100644 index 00000000..88762455 --- /dev/null +++ b/app/client.tsx @@ -0,0 +1,20 @@ +import { StrictMode, startTransition } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { StartClient } from "@tanstack/react-start/client"; +import { parseError } from "./client/lib/errors"; + +startTransition(() => { + hydrateRoot( + document, + + + , + { + onRecoverableError: (error, errorInfo) => { + console.error( + `[react-recoverable-error] ${parseError(error)?.message}${errorInfo.componentStack ? `\n${errorInfo.componentStack.trim()}` : ""}`, + ); + }, + }, + ); +}); diff --git a/app/client/components/app-breadcrumb.tsx b/app/client/components/app-breadcrumb.tsx index d419708a..136a9837 100644 --- a/app/client/components/app-breadcrumb.tsx +++ b/app/client/components/app-breadcrumb.tsx @@ -1,3 +1,4 @@ +import { Fragment } from "react"; import { useMatches, Link } from "@tanstack/react-router"; import { Breadcrumb, @@ -41,7 +42,7 @@ export function AppBreadcrumb() { const isLast = index === breadcrumbs.length - 1; return ( -
+ {isLast || !breadcrumb.href ? ( {breadcrumb.label} @@ -52,7 +53,7 @@ export function AppBreadcrumb() { )} {!isLast && } -
+ ); })} diff --git a/app/client/components/layout.tsx b/app/client/components/layout.tsx index 9bb084e2..ff925f42 100644 --- a/app/client/components/layout.tsx +++ b/app/client/components/layout.tsx @@ -2,7 +2,7 @@ import { LifeBuoy, LogOut } from "lucide-react"; import { toast } from "sonner"; import { type AppContext } from "~/context"; import { GridBackground } from "./grid-background"; -import { Button } from "./ui/button"; +import { Button, buttonVariants } from "./ui/button"; import { SidebarProvider, SidebarTrigger } from "./ui/sidebar"; import { AppSidebar } from "./app-sidebar"; import { authClient } from "../lib/auth-client"; @@ -63,20 +63,19 @@ export function Layout({ loaderData }: Props) { - + + Report an issue diff --git a/app/client/lib/datetime.ts b/app/client/lib/datetime.ts index 2a7ada92..15df41ca 100644 --- a/app/client/lib/datetime.ts +++ b/app/client/lib/datetime.ts @@ -99,6 +99,10 @@ export function formatTime(date: DateInput, options: DateFormatOptions = {}): st // 5 minutes ago export function formatTimeAgo(date: DateInput): string { return formatValidDate(date, (validDate) => { + if (Math.abs(Date.now() - validDate.getTime()) < 120_000) { + return "just now"; + } + const timeAgo = formatDistanceToNow(validDate, { addSuffix: true, includeSeconds: true, diff --git a/app/client/modules/volumes/routes/volumes.tsx b/app/client/modules/volumes/routes/volumes.tsx index db2f146b..9c21c0a4 100644 --- a/app/client/modules/volumes/routes/volumes.tsx +++ b/app/client/modules/volumes/routes/volumes.tsx @@ -133,7 +133,7 @@ export function VolumesPage() { {filteredVolumes.map((volume) => ( navigate({ to: `/volumes/${volume.shortId}` })} > diff --git a/app/routes/__root.tsx b/app/routes/__root.tsx index d95d4cdb..da90a629 100644 --- a/app/routes/__root.tsx +++ b/app/routes/__root.tsx @@ -62,6 +62,10 @@ export function RootLayout() { useServerEvents({ enabled: !isAuthRoute(pathname) }); useEffect(() => { document.body.setAttribute("data-app-ready", "true"); + window.addEventListener("vite:preloadError", () => { + window.location.reload(); + }); + return () => { document.body.removeAttribute("data-app-ready"); }; diff --git a/app/server/modules/volumes/volume.service.ts b/app/server/modules/volumes/volume.service.ts index 9005918c..fe38ef90 100644 --- a/app/server/modules/volumes/volume.service.ts +++ b/app/server/modules/volumes/volume.service.ts @@ -24,6 +24,7 @@ const listVolumes = async () => { const organizationId = getOrganizationId(); const volumes = await db.query.volumesTable.findMany({ where: { organizationId: organizationId }, + orderBy: { id: "asc" }, }); return volumes; diff --git a/e2e/0003-oidc.spec.ts b/e2e/0003-oidc.spec.ts index c0623042..c028fac2 100644 --- a/e2e/0003-oidc.spec.ts +++ b/e2e/0003-oidc.spec.ts @@ -207,7 +207,11 @@ async function withOidcLoginAttempt( origins: [], }, }); - const browserErrorTracker = trackBrowserErrors(context); + const browserErrorTracker = trackBrowserErrors(context, { + attach: async (name, body, contentType) => { + await test.info().attach(name, { body, contentType }); + }, + }); const page = await context.newPage(); try { @@ -224,7 +228,7 @@ async function withOidcLoginAttempt( } await assertions(page); - browserErrorTracker.assertNoBrowserErrors(); + await browserErrorTracker.assertNoBrowserErrors(); } finally { await context.close().catch(() => undefined); } diff --git a/e2e/helpers/browser-errors.ts b/e2e/helpers/browser-errors.ts index 7a1fdfda..acc677da 100644 --- a/e2e/helpers/browser-errors.ts +++ b/e2e/helpers/browser-errors.ts @@ -7,6 +7,26 @@ const IGNORED_PATTERNS: RegExp[] = [ const isIgnoredError = (text: string) => IGNORED_PATTERNS.some((pattern) => pattern.test(text)); +const isAbortedFetchConsoleError = (record: BrowserErrorRecord, records: BrowserErrorRecord[]) => { + if (record.type !== "console.error" || !record.message.includes("TypeError: Failed to fetch")) { + return false; + } + + const recordTimestamp = Date.parse(record.timestamp); + + return records.some((candidate) => { + if (candidate.type !== "requestfailed" || candidate.pageId !== record.pageId) { + return false; + } + + if (!candidate.message.includes("net::ERR_ABORTED")) { + return false; + } + + return Math.abs(Date.parse(candidate.timestamp) - recordTimestamp) <= 1000; + }); +}; + const formatConsoleMessage = (message: ConsoleMessage) => { const location = message.location(); const hasLocation = !!location.url; @@ -17,9 +37,108 @@ const formatConsoleMessage = (message: ConsoleMessage) => { return `console.error${formattedLocation}\n${message.text()}`; }; -export const trackBrowserErrors = (context: BrowserContext) => { - const browserErrors: string[] = []; +type BrowserErrorRecord = { + type: "console.error" | "pageerror" | "requestfailed"; + pageId: string; + pageUrl: string; + timestamp: string; + message: string; +}; + +type FirstPageErrorSnapshot = { + pageId: string; + pageUrl: string; + timestamp: string; + title: string; + html: string; + clientLocale: string; + clientLanguages: string[]; + clientTimeZone: string; + appReady: string | null; +}; + +type TrackBrowserErrorsOptions = { + attach?: (name: string, body: string | Buffer, contentType: string) => Promise; +}; + +const formatBrowserErrorRecord = (record: BrowserErrorRecord) => { + return `[${record.timestamp}] ${record.type} on ${record.pageId} (${record.pageUrl})\n${record.message}`; +}; + +async function captureFirstPageErrorSnapshot( + page: Page, + pageId: string, +): Promise<{ + screenshot: Buffer; + snapshot: FirstPageErrorSnapshot; +}> { + const timestamp = new Date().toISOString(); + const pageUrl = page.url(); + const screenshot = await page.screenshot({ fullPage: true, type: "png" }); + const snapshot = await page.evaluate( + ({ currentPageId, currentTimestamp }) => ({ + pageId: currentPageId, + pageUrl: window.location.href, + timestamp: currentTimestamp, + title: document.title, + html: document.documentElement.outerHTML, + clientLocale: navigator.language, + clientLanguages: [...navigator.languages], + clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + appReady: document.body?.getAttribute("data-app-ready") ?? null, + }), + { currentPageId: pageId, currentTimestamp: timestamp }, + ); + + return { + screenshot, + snapshot: { + ...snapshot, + pageUrl: snapshot.pageUrl || pageUrl, + }, + }; +} + +export const trackBrowserErrors = (context: BrowserContext, options: TrackBrowserErrorsOptions = {}) => { + const browserErrors: BrowserErrorRecord[] = []; const trackedPages = new WeakSet(); + const pageIds = new WeakMap(); + let nextPageId = 1; + let firstPageErrorSnapshotPromise: Promise>> | undefined; + + void context.addInitScript(() => { + window.addEventListener("unhandledrejection", (event) => { + const reason = + event.reason instanceof Error + ? (event.reason.stack ?? event.reason.message) + : typeof event.reason === "string" + ? event.reason + : JSON.stringify(event.reason); + + console.error(`[unhandledrejection]\n${reason}`); + }); + }); + + const getPageId = (page: Page) => { + const existingPageId = pageIds.get(page); + if (existingPageId) { + return existingPageId; + } + + const pageId = `page-${nextPageId++}`; + pageIds.set(page, pageId); + return pageId; + }; + + const pushRecord = (page: Page, type: BrowserErrorRecord["type"], message: string) => { + browserErrors.push({ + type, + pageId: getPageId(page), + pageUrl: page.url(), + timestamp: new Date().toISOString(), + message, + }); + }; const trackPage = (page: Page) => { if (trackedPages.has(page)) { @@ -27,6 +146,7 @@ export const trackBrowserErrors = (context: BrowserContext) => { } trackedPages.add(page); + getPageId(page); page.on("console", (message) => { if (message.type() !== "error") { @@ -37,11 +157,24 @@ export const trackBrowserErrors = (context: BrowserContext) => { return; } - browserErrors.push(formatConsoleMessage(message)); + pushRecord(page, "console.error", formatConsoleMessage(message)); }); page.on("pageerror", (error) => { - browserErrors.push(`pageerror\n${error.stack ?? error.message}`); + pushRecord(page, "pageerror", error.stack ?? error.message); + + if (!firstPageErrorSnapshotPromise && !page.isClosed()) { + firstPageErrorSnapshotPromise = captureFirstPageErrorSnapshot(page, getPageId(page)); + } + }); + + page.on("requestfailed", (request) => { + const failure = request.failure(); + if (!failure) { + return; + } + + pushRecord(page, "requestfailed", `${request.method()} ${request.url()}\n${failure.errorText}`); }); }; @@ -52,12 +185,65 @@ export const trackBrowserErrors = (context: BrowserContext) => { context.on("page", trackPage); return { - assertNoBrowserErrors() { - if (browserErrors.length === 0) { + async assertNoBrowserErrors() { + const failingBrowserErrors = browserErrors.filter((record) => { + if (record.type === "requestfailed") { + return false; + } + + return !isAbortedFetchConsoleError(record, browserErrors); + }); + + if (failingBrowserErrors.length === 0) { return; } - throw new Error(`Browser console errors detected:\n\n${browserErrors.join("\n\n")}`); + if (options.attach) { + await options.attach( + "browser-errors.md", + [ + "# Browser error diagnostics", + "", + ...browserErrors.flatMap((record) => [formatBrowserErrorRecord(record), ""]), + ].join("\n"), + "text/markdown", + ); + await options.attach( + "browser-errors.json", + JSON.stringify( + { + records: browserErrors, + firstPageErrorSnapshot: null, + }, + null, + 2, + ), + "application/json", + ); + } + + const firstPageErrorSnapshot = firstPageErrorSnapshotPromise ? await firstPageErrorSnapshotPromise : undefined; + + if (options.attach && firstPageErrorSnapshot) { + await options.attach( + "browser-errors.json", + JSON.stringify( + { + records: browserErrors, + firstPageErrorSnapshot: firstPageErrorSnapshot.snapshot, + }, + null, + 2, + ), + "application/json", + ); + await options.attach("first-pageerror.png", firstPageErrorSnapshot.screenshot, "image/png"); + await options.attach("first-pageerror.html", firstPageErrorSnapshot.snapshot.html, "text/html"); + } + + throw new Error( + `Browser console errors detected:\n\n${failingBrowserErrors.map(formatBrowserErrorRecord).join("\n\n")}`, + ); }, }; }; diff --git a/e2e/test.ts b/e2e/test.ts index 8847fade..49a2a40f 100644 --- a/e2e/test.ts +++ b/e2e/test.ts @@ -2,12 +2,16 @@ 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); + context: async ({ context }, use, testInfo) => { + const browserErrorTracker = trackBrowserErrors(context, { + attach: async (name, body, contentType) => { + await testInfo.attach(name, { body, contentType }); + }, + }); await use(context); - browserErrorTracker.assertNoBrowserErrors(); + await browserErrorTracker.assertNoBrowserErrors(); }, });