test(e2e): attach browser logs and full errors in report

This commit is contained in:
Nicolas Meienberger
2026-03-23 21:13:09 +01:00
parent a35c85ddfa
commit 572af732ef
11 changed files with 253 additions and 29 deletions

View File

@@ -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

20
app/client.tsx Normal file
View File

@@ -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,
<StrictMode>
<StartClient />
</StrictMode>,
{
onRecoverableError: (error, errorInfo) => {
console.error(
`[react-recoverable-error] ${parseError(error)?.message}${errorInfo.componentStack ? `\n${errorInfo.componentStack.trim()}` : ""}`,
);
},
},
);
});

View File

@@ -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 (
<div key={`${breadcrumb.label}-${index}`} className="contents">
<Fragment key={`${breadcrumb.label}-${index}`}>
<BreadcrumbItem>
{isLast || !breadcrumb.href ? (
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
@@ -52,7 +53,7 @@ export function AppBreadcrumb() {
)}
</BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />}
</div>
</Fragment>
);
})}
</BreadcrumbList>

View File

@@ -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) {
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-foreground"
<a
href="https://github.com/nicotsx/zerobyte/issues/new"
target="_blank"
rel="noreferrer"
className={buttonVariants({
variant: "ghost",
size: "icon",
className:
"relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-foreground",
})}
>
<a
href="https://github.com/nicotsx/zerobyte/issues/new"
target="_blank"
rel="noreferrer"
className="flex items-center justify-center w-full h-full"
>
<LifeBuoy className="w-4 h-4" />
</a>
</Button>
<LifeBuoy className="w-4 h-4" />
</a>
</TooltipTrigger>
<TooltipContent>Report an issue</TooltipContent>
</Tooltip>

View File

@@ -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,

View File

@@ -133,7 +133,7 @@ export function VolumesPage() {
</TableRow>
{filteredVolumes.map((volume) => (
<TableRow
key={volume.name}
key={volume.shortId}
className="hover:bg-muted/50 hover:cursor-pointer transition-colors h-12"
onClick={() => navigate({ to: `/volumes/${volume.shortId}` })}
>

View File

@@ -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");
};

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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<void>;
};
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<Page>();
const pageIds = new WeakMap<Page, string>();
let nextPageId = 1;
let firstPageErrorSnapshotPromise: Promise<Awaited<ReturnType<typeof captureFirstPageErrorSnapshot>>> | 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")}`,
);
},
};
};

View File

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