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();
},
});