Skip to content
Merged
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
72 changes: 71 additions & 1 deletion src/components/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";

Expand Down Expand Up @@ -444,4 +444,74 @@ describe("ErrorBoundary", () => {
expect(screen.queryByText("Show component stack")).not.toBeInTheDocument();
});
});

describe("copy error details (#953)", () => {
it("renders a 'Copy error details' button in the fallback UI", () => {
vi.spyOn(console, "error").mockImplementation(() => {});

render(
<ErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</ErrorBoundary>
);

expect(
screen.getByRole("button", { name: /copy error details/i })
).toBeInTheDocument();
});

it("copies a diagnostics report and confirms with 'Copied!'", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const writeText = vi.fn().mockResolvedValue(undefined);
const originalClipboard = navigator.clipboard;
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});

render(
<ErrorBoundary>
<ThrowingComponent
shouldThrow={true}
errorMessage="Diagnostics boom"
/>
</ErrorBoundary>
);

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

await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
const report = writeText.mock.calls[0][0] as string;
expect(report).toContain("sorokit-ui error report");
expect(report).toContain("Diagnostics boom");

await waitFor(() =>
expect(
screen.getByRole("button", { name: /copied!/i })
).toBeInTheDocument()
);

Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: originalClipboard,
});
});

it("keeps the details copyable even when production hides the message", () => {
vi.stubEnv("DEV", false);
vi.spyOn(console, "error").mockImplementation(() => {});

render(
<ErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</ErrorBoundary>
);

expect(
screen.getByRole("button", { name: /copy error details/i })
).toBeInTheDocument();
});
});
});
72 changes: 68 additions & 4 deletions src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { AlertCircleIcon, Refresh01Icon } from "@hugeicons/core-free-icons";
import {
AlertCircleIcon,
Copy01Icon,
Refresh01Icon,
Tick01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Component, type ErrorInfo, type ReactNode } from "react";

import { cn } from "../lib/utils";
import { copyToClipboard } from "./AccountCard";

interface Props {
children: ReactNode;
Expand All @@ -27,15 +33,27 @@ interface State {
error: Error | null;
resetKey: number;
componentStack: string | null;
copied: boolean;
}

export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null, resetKey: 0, componentStack: null };
state: State = {
error: null,
resetKey: 0,
componentStack: null,
copied: false,
};

private copyResetTimer: ReturnType<typeof setTimeout> | null = null;

static getDerivedStateFromError(error: Error): Partial<State> {
return { error };
}

componentWillUnmount() {
if (this.copyResetTimer) clearTimeout(this.copyResetTimer);
}

componentDidCatch(error: Error, info: ErrorInfo) {
this.setState({ error, componentStack: info.componentStack ?? null });

Expand All @@ -53,15 +71,48 @@ export class ErrorBoundary extends Component<Props, State> {
// Runs before the state clears so a re-initialisation attempt is already
// done by the time children re-mount under the new resetKey.
this.props.onRetry?.();
if (this.copyResetTimer) clearTimeout(this.copyResetTimer);
this.setState((state) => ({
error: null,
resetKey: state.resetKey + 1,
componentStack: null,
copied: false,
}));
};

private buildDiagnostics() {
const { error, componentStack } = this.state;
const lines = [
"sorokit-ui error report",
`Time: ${new Date().toISOString()}`,
`URL: ${typeof window !== "undefined" ? window.location.href : "unknown"}`,
`User agent: ${
typeof navigator !== "undefined" ? navigator.userAgent : "unknown"
}`,
"",
`Message: ${error?.message ?? "Unknown error"}`,
];

if (error?.stack) lines.push("", "Stack:", error.stack);
if (componentStack) lines.push("", "Component stack:", componentStack);

return lines.join("\n");
}

handleCopy = async () => {
const ok = await copyToClipboard(this.buildDiagnostics());
if (!ok) return;

if (this.copyResetTimer) clearTimeout(this.copyResetTimer);
this.setState({ copied: true });
this.copyResetTimer = setTimeout(() => {
this.copyResetTimer = null;
this.setState({ copied: false });
}, 2000);
};

render() {
const { error, resetKey, componentStack } = this.state;
const { error, resetKey, componentStack, copied } = this.state;
const { children, fallback, isolate, supportUrl } = this.props;

if (!error) return <div key={resetKey}>{children}</div>;
Expand Down Expand Up @@ -138,7 +189,20 @@ export class ErrorBoundary extends Component<Props, State> {
</pre>
</details>
)}
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center justify-center gap-3">
<button
onClick={this.handleCopy}
aria-live="polite"
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg bg-surface-2 border border-line hover:border-line-2 text-[13px] text-ink-2 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
>
<HugeiconsIcon
icon={copied ? Tick01Icon : Copy01Icon}
size={14}
color="currentColor"
strokeWidth={1.5}
/>
{copied ? "Copied!" : "Copy error details"}
</button>
<button
onClick={this.reset}
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg bg-surface-2 border border-line hover:border-line-2 text-[13px] text-ink-2 transition-colors cursor-pointer"
Expand Down
68 changes: 67 additions & 1 deletion src/components/QRCode.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import QRCodeLib from "qrcode";
import { describe, expect, it, vi } from "vitest";

Expand All @@ -13,6 +13,7 @@ vi.mock("qrcode", () => {
}
return Promise.resolve();
}),
toString: vi.fn(() => Promise.resolve("<svg><rect /></svg>")),
},
};
});
Expand Down Expand Up @@ -200,4 +201,69 @@ describe("QRCode", () => {
);
warnSpy.mockRestore();
});

describe("accessible description (#953)", () => {
it("links a visually-hidden description to the canvas", () => {
render(<QRCode value={value} description="Scan to receive funds." />);
const img = screen.getByRole("img");
const describedBy = img.getAttribute("aria-describedby");

expect(describedBy).toBeTruthy();
expect(document.getElementById(describedBy!)).toHaveTextContent(
"Scan to receive funds.",
);
});

it("falls back to a generated description when none is provided", () => {
render(<QRCode value={value} />);
const img = screen.getByRole("img");
const describedBy = img.getAttribute("aria-describedby");

expect(describedBy).toBeTruthy();
expect(document.getElementById(describedBy!)).toHaveTextContent(
new RegExp(`QR code encoding ${value}`),
);
});
});

describe("download action (#953)", () => {
it("re-encodes the value as SVG and triggers a download", async () => {
const createObjectURL = vi.fn(() => "blob:mock-url");
const revokeObjectURL = vi.fn();
const originalCreate = URL.createObjectURL;
const originalRevoke = URL.revokeObjectURL;
URL.createObjectURL =
createObjectURL as unknown as typeof URL.createObjectURL;
URL.revokeObjectURL =
revokeObjectURL as unknown as typeof URL.revokeObjectURL;

const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {});

render(<QRCode value={value} downloadFileName="my-wallet" />);
fireEvent.click(
screen.getByRole("button", { name: /download qr code/i }),
);

await waitFor(() => expect(QRCodeLib.toString).toHaveBeenCalled());
const [, options] = vi.mocked(QRCodeLib.toString).mock.calls[0];
expect(options).toMatchObject({ type: "svg" });
expect(createObjectURL).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:mock-url");

clickSpy.mockRestore();
URL.createObjectURL = originalCreate;
URL.revokeObjectURL = originalRevoke;
});

it("does not render the download action when generation failed", () => {
getContextSpy.mockReturnValue(null);
render(<QRCode value={value} />);
expect(
screen.queryByRole("button", { name: /download qr code/i }),
).not.toBeInTheDocument();
});
});
});
Loading
Loading