Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/ui/src/components.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export { Button, type ButtonProps } from "./components/button";
export {
ErrorWrapper,
type ErrorWrapperProps,
} from "./components/ErrorWrapper";
export {
Loading,
LoadingBar,
Expand Down
111 changes: 111 additions & 0 deletions packages/ui/src/components/ErrorWrapper.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { ErrorWrapper } from "./ErrorWrapper";

const STORY_ERROR = new Error("The dashboard request failed with HTTP 502", {
cause: "upstream service unavailable",
});
STORY_ERROR.stack = [
"Error: The dashboard request failed with HTTP 502",
" at loadDashboard (src/pages/Dashboard.tsx:84:15)",
" at Dashboard (src/pages/Dashboard.tsx:27:3)",
].join("\n");

function BrokenDashboard(): never {
throw STORY_ERROR;
}

function mockClipboard(writeText: (text: string) => Promise<void>): () => void {
const descriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard");
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
return () => {
if (descriptor) {
Object.defineProperty(navigator, "clipboard", descriptor);
} else {
Reflect.deleteProperty(navigator, "clipboard");
}
};
}

const meta = {
title: "Components/ErrorWrapper",
component: ErrorWrapper,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"Full-page React error boundary with normalized diagnostics and a support-ready copy action.",
},
},
},
argTypes: {
children: { control: false },
onError: { control: false },
},
} satisfies Meta<typeof ErrorWrapper>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
render: () => (
<ErrorWrapper>
<BrokenDashboard />
</ErrorWrapper>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("alert")).toBeInTheDocument();
await expect(
canvas.getByRole("heading", { name: "Something went wrong" }),
).toBeInTheDocument();
},
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const CopySuccess: Story = {
...Default,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const restoreClipboard = mockClipboard(() => Promise.resolve());
try {
await userEvent.click(
canvas.getByRole("button", { name: "Copy error details" }),
);
await expect(
canvas.getByRole("button", { name: "Copied" }),
).toBeInTheDocument();
await expect(
canvas.getByText("Error details copied to clipboard."),
).toBeInTheDocument();
} finally {
restoreClipboard();
}
},
};

export const ClipboardFailure: Story = {
...Default,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const restoreClipboard = mockClipboard(() =>
Promise.reject(new Error("Clipboard access denied")),
);
try {
await userEvent.click(
canvas.getByRole("button", { name: "Copy error details" }),
);
await expect(
canvas.getByText(
"Clipboard access failed. Expand the error details to copy individual values.",
),
).toBeInTheDocument();
} finally {
restoreClipboard();
}
},
};
111 changes: 111 additions & 0 deletions packages/ui/src/components/ErrorWrapper.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ErrorWrapper } from "./ErrorWrapper";

function BrokenPage(): never {
throw new Error("Unable to load the account dashboard", {
cause: "upstream request returned HTTP 502",
});
}

const originalClipboardDescriptor = Object.getOwnPropertyDescriptor(
navigator,
"clipboard",
);
const originalPageUrl = window.location.href;

describe("ErrorWrapper", () => {
afterEach(() => {
vi.restoreAllMocks();
if (originalClipboardDescriptor) {
Object.defineProperty(
navigator,
"clipboard",
originalClipboardDescriptor,
);
} else {
Reflect.deleteProperty(navigator, "clipboard");
}
window.history.replaceState({}, "", originalPageUrl);
});

it("renders a full-page diagnostic fallback and copies a support-ready report", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
window.history.replaceState(
{},
"",
"/accounts?access_token=secret#authorization-code",
);
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

render(
<ErrorWrapper>
<BrokenPage />
</ErrorWrapper>,
);

const fallback = screen.getByRole("alert");
expect(fallback).toHaveClass("min-h-dvh");
expect(
screen.getByRole("heading", { name: "Something went wrong" }),
).toBeInTheDocument();
expect(fallback).toHaveTextContent("Unable to load the account dashboard");

fireEvent.click(screen.getByRole("button", { name: "Copy error details" }));

await waitFor(() => expect(writeText).toHaveBeenCalledOnce());
const report = writeText.mock.calls[0]?.[0];
expect(report).toEqual(
expect.stringContaining("Error: Unable to load the account dashboard"),
);
expect(report).toEqual(
expect.stringContaining("Cause: upstream request returned HTTP 502"),
);
expect(report).toEqual(
expect.stringContaining(`Page: ${window.location.origin}/accounts`),
);
expect(report).not.toEqual(expect.stringContaining("access_token"));
expect(report).not.toEqual(expect.stringContaining("authorization-code"));
expect(report).toEqual(expect.stringContaining("React component stack:"));
expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument();
expect(liveRegion(fallback)).toHaveTextContent(
"Error details copied to clipboard.",
);
});

it("announces a clipboard failure in the live region", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const writeText = vi.fn().mockRejectedValue(new Error("denied"));
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});

render(
<ErrorWrapper>
<BrokenPage />
</ErrorWrapper>,
);

const fallback = screen.getByRole("alert");
expect(liveRegion(fallback)).toHaveTextContent("");

fireEvent.click(screen.getByRole("button", { name: "Copy error details" }));

await waitFor(() =>
expect(liveRegion(fallback)).toHaveTextContent(
"Clipboard access failed. Expand the error details to copy individual values.",
),
);
});
});

function liveRegion(fallback: HTMLElement): HTMLElement {
const region = fallback.querySelector<HTMLElement>('[aria-live="polite"]');
if (!region) throw new Error("ErrorWrapper is missing its live region");
return region;
}
Loading