mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-04-19 06:18:12 -04:00
test(e2e): attach browser logs and full errors in report
This commit is contained in:
1
.github/workflows/e2e.yml
vendored
1
.github/workflows/e2e.yml
vendored
@@ -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
20
app/client.tsx
Normal 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()}` : ""}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}` })}
|
||||
>
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
10
e2e/test.ts
10
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();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user