mirror of
https://github.com/vercel/ai-elements.git
synced 2026-09-14 19:48:26 +08:00
605 lines
16 KiB
TypeScript
605 lines
16 KiB
TypeScript
import { render, screen } from "@testing-library/react";
|
|
import { userEvent } from "@testing-library/user-event";
|
|
|
|
import {
|
|
StackTrace,
|
|
StackTraceActions,
|
|
StackTraceContent,
|
|
StackTraceCopyButton,
|
|
StackTraceError,
|
|
StackTraceErrorMessage,
|
|
StackTraceErrorType,
|
|
StackTraceExpandButton,
|
|
StackTraceFrames,
|
|
StackTraceHeader,
|
|
} from "../src/stack-trace";
|
|
|
|
const RENDER_WITH_HOOKS_REGEX = /renderWithHooks/;
|
|
const BEGIN_WORK_REGEX = /beginWork/;
|
|
const NODE_FS_REGEX = /node:fs/;
|
|
const ASYNC_FETCH_DATA_REGEX = /async fetchData/;
|
|
|
|
const sampleStackTrace = `TypeError: Cannot read properties of undefined (reading 'map')
|
|
at UserList (/app/components/UserList.tsx:15:23)
|
|
at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:14985:18)
|
|
at beginWork (node_modules/react-dom/cjs/react-dom.development.js:19049:16)`;
|
|
|
|
const simpleStackTrace = `Error: Something went wrong
|
|
at myFunction (/src/index.ts:10:5)
|
|
at main (/src/main.ts:20:10)`;
|
|
|
|
const nodeInternalTrace = `Error: ENOENT
|
|
at readFile (node:fs:123:10)
|
|
at internal/modules/cjs/loader.js:50:20`;
|
|
|
|
describe("stackTrace", () => {
|
|
it("renders children", () => {
|
|
render(<StackTrace trace={sampleStackTrace}>Content</StackTrace>);
|
|
expect(screen.getByText("Content")).toBeInTheDocument();
|
|
});
|
|
|
|
it("throws error when component used outside provider", () => {
|
|
const spy = vi.spyOn(console, "error").mockImplementation(vi.fn());
|
|
|
|
expect(() => render(<StackTraceHeader>Test</StackTraceHeader>)).toThrow(
|
|
"StackTrace components must be used within StackTrace"
|
|
);
|
|
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it("starts closed by default", () => {
|
|
render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceHeader />
|
|
<StackTraceContent>
|
|
<StackTraceFrames />
|
|
</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
const content = screen.queryByText("UserList");
|
|
expect(content).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("can start open", () => {
|
|
const { container } = render(
|
|
<StackTrace defaultOpen trace={sampleStackTrace}>
|
|
<StackTraceHeader />
|
|
<StackTraceContent>
|
|
<StackTraceFrames />
|
|
</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
// Check content is visible by finding frames container
|
|
const framesContainer = container.querySelector(".space-y-1");
|
|
expect(framesContainer).toBeVisible();
|
|
});
|
|
|
|
it("calls onOpenChange", async () => {
|
|
const onOpenChange = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
const { container } = render(
|
|
<StackTrace onOpenChange={onOpenChange} trace={sampleStackTrace}>
|
|
<StackTraceHeader />
|
|
<StackTraceContent>
|
|
<StackTraceFrames />
|
|
</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
const trigger = container.querySelector(
|
|
"[data-slot='collapsible-trigger']"
|
|
);
|
|
expect(trigger).toBeInTheDocument();
|
|
await user.click(trigger as Element);
|
|
|
|
expect(onOpenChange).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it("applies custom className", () => {
|
|
const { container } = render(
|
|
<StackTrace className="custom-class" trace={sampleStackTrace}>
|
|
Content
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(container.firstChild).toHaveClass("custom-class");
|
|
});
|
|
});
|
|
|
|
describe("stackTraceHeader", () => {
|
|
it("renders as clickable trigger", () => {
|
|
const { container } = render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceHeader>Header Content</StackTraceHeader>
|
|
</StackTrace>
|
|
);
|
|
|
|
const trigger = container.querySelector(
|
|
"[data-slot='collapsible-trigger']"
|
|
);
|
|
expect(trigger).toBeInTheDocument();
|
|
expect(screen.getByText("Header Content")).toBeInTheDocument();
|
|
});
|
|
|
|
it("toggles content on click", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
const { container } = render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceHeader />
|
|
<StackTraceContent>
|
|
<StackTraceFrames />
|
|
</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
// Check content is not visible initially
|
|
const framesContainer = container.querySelector(".space-y-1");
|
|
expect(framesContainer).not.toBeInTheDocument();
|
|
|
|
const trigger = container.querySelector(
|
|
"[data-slot='collapsible-trigger']"
|
|
);
|
|
expect(trigger).toBeInTheDocument();
|
|
await user.click(trigger as Element);
|
|
|
|
// Now frames container should be visible
|
|
const visibleFrames = container.querySelector(".space-y-1");
|
|
expect(visibleFrames).toBeVisible();
|
|
});
|
|
});
|
|
|
|
describe("stackTraceError", () => {
|
|
it("renders children", () => {
|
|
render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceError>Error Info</StackTraceError>
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(screen.getByText("Error Info")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders alert icon", () => {
|
|
const { container } = render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceError>Error</StackTraceError>
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(container.querySelector("svg")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("stackTraceErrorType", () => {
|
|
it("renders parsed error type", () => {
|
|
render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceErrorType />
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(screen.getByText("TypeError")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders custom children", () => {
|
|
render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceErrorType>CustomError</StackTraceErrorType>
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(screen.getByText("CustomError")).toBeInTheDocument();
|
|
});
|
|
|
|
it("handles trace without error type", () => {
|
|
const traceWithoutType = "Something went wrong\n at foo (/bar.js:1:1)";
|
|
|
|
render(
|
|
<StackTrace trace={traceWithoutType}>
|
|
<StackTraceErrorType />
|
|
</StackTrace>
|
|
);
|
|
|
|
// Should render empty when no error type
|
|
expect(screen.queryByText("Error")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("stackTraceErrorMessage", () => {
|
|
it("renders parsed error message", () => {
|
|
render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceErrorMessage />
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(
|
|
screen.getByText("Cannot read properties of undefined (reading 'map')")
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders custom children", () => {
|
|
render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceErrorMessage>Custom message</StackTraceErrorMessage>
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(screen.getByText("Custom message")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("stackTraceActions", () => {
|
|
it("renders children", () => {
|
|
render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceActions>
|
|
<button type="button">Action</button>
|
|
</StackTraceActions>
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(screen.getByText("Action")).toBeInTheDocument();
|
|
});
|
|
|
|
it("stops event propagation on click", async () => {
|
|
const actionClick = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceActions>
|
|
<button onClick={actionClick} type="button">
|
|
Action
|
|
</button>
|
|
</StackTraceActions>
|
|
</StackTrace>
|
|
);
|
|
|
|
await user.click(screen.getByText("Action"));
|
|
|
|
expect(actionClick).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
const setupCopyButtonTests = () => {
|
|
vi.clearAllMocks();
|
|
};
|
|
|
|
describe("stackTraceCopyButton", () => {
|
|
it("renders copy button", () => {
|
|
setupCopyButtonTests();
|
|
render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceCopyButton />
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(screen.getByRole("button")).toBeInTheDocument();
|
|
});
|
|
|
|
it("copies trace to clipboard", async () => {
|
|
const user = userEvent.setup();
|
|
const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText");
|
|
|
|
render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceCopyButton />
|
|
</StackTrace>
|
|
);
|
|
|
|
await user.click(screen.getByRole("button"));
|
|
|
|
expect(writeTextSpy).toHaveBeenCalledWith(sampleStackTrace);
|
|
});
|
|
|
|
it("calls onCopy callback", async () => {
|
|
const onCopy = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceCopyButton onCopy={onCopy} />
|
|
</StackTrace>
|
|
);
|
|
|
|
await user.click(screen.getByRole("button"));
|
|
|
|
expect(onCopy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls onError when clipboard fails", async () => {
|
|
const onError = vi.fn();
|
|
const user = userEvent.setup();
|
|
const error = new Error("Clipboard error");
|
|
|
|
vi.spyOn(navigator.clipboard, "writeText").mockRejectedValueOnce(error);
|
|
|
|
render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceCopyButton onError={onError} />
|
|
</StackTrace>
|
|
);
|
|
|
|
await user.click(screen.getByRole("button"));
|
|
|
|
await vi.waitFor(() => {
|
|
expect(onError).toHaveBeenCalledWith(error);
|
|
});
|
|
});
|
|
|
|
it("calls onError when clipboard API is not available", async () => {
|
|
const onError = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
const originalClipboard = navigator.clipboard;
|
|
Object.defineProperty(navigator, "clipboard", {
|
|
configurable: true,
|
|
value: { writeText: undefined },
|
|
writable: true,
|
|
});
|
|
|
|
render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceCopyButton onError={onError} />
|
|
</StackTrace>
|
|
);
|
|
|
|
await user.click(screen.getByRole("button"));
|
|
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "Clipboard API not available",
|
|
})
|
|
);
|
|
|
|
Object.defineProperty(navigator, "clipboard", {
|
|
configurable: true,
|
|
value: originalClipboard,
|
|
writable: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("stackTraceExpandButton", () => {
|
|
it("renders chevron icon", () => {
|
|
const { container } = render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceExpandButton />
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(container.querySelector("svg")).toBeInTheDocument();
|
|
});
|
|
|
|
it("rotates when open", () => {
|
|
const { container } = render(
|
|
<StackTrace defaultOpen trace={sampleStackTrace}>
|
|
<StackTraceExpandButton />
|
|
</StackTrace>
|
|
);
|
|
|
|
const svg = container.querySelector("svg");
|
|
expect(svg).toHaveClass("rotate-180");
|
|
});
|
|
|
|
it("does not rotate when closed", () => {
|
|
const { container } = render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceExpandButton />
|
|
</StackTrace>
|
|
);
|
|
|
|
const svg = container.querySelector("svg");
|
|
expect(svg).toHaveClass("rotate-0");
|
|
});
|
|
});
|
|
|
|
describe("stackTraceContent", () => {
|
|
it("renders content when open", () => {
|
|
render(
|
|
<StackTrace defaultOpen trace={sampleStackTrace}>
|
|
<StackTraceContent>Content text</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(screen.getByText("Content text")).toBeInTheDocument();
|
|
});
|
|
|
|
it("hides content when closed", () => {
|
|
render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceContent>Hidden content</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(screen.queryByText("Hidden content")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("applies maxHeight style", () => {
|
|
const { container } = render(
|
|
<StackTrace defaultOpen trace={sampleStackTrace}>
|
|
<StackTraceContent maxHeight={200}>Content</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
const content = container.querySelector(
|
|
"[data-slot='collapsible-content']"
|
|
);
|
|
expect(content).toHaveAttribute(
|
|
"style",
|
|
expect.stringContaining("max-height: 200px")
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("stackTraceFrames", () => {
|
|
it("renders stack frames", () => {
|
|
const { container } = render(
|
|
<StackTrace defaultOpen trace={simpleStackTrace}>
|
|
<StackTraceContent>
|
|
<StackTraceFrames />
|
|
</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
// Check frames are rendered
|
|
const frames = container.querySelectorAll(".text-xs");
|
|
expect(frames).toHaveLength(2);
|
|
});
|
|
|
|
it("renders file paths with line numbers", () => {
|
|
render(
|
|
<StackTrace defaultOpen trace={simpleStackTrace}>
|
|
<StackTraceContent>
|
|
<StackTraceFrames />
|
|
</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(screen.getByText("/src/index.ts:10:5")).toBeInTheDocument();
|
|
});
|
|
|
|
it("dims internal frames", () => {
|
|
const { container } = render(
|
|
<StackTrace defaultOpen trace={sampleStackTrace}>
|
|
<StackTraceContent>
|
|
<StackTraceFrames />
|
|
</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
// Find the user frame (UserList) and internal frame (renderWithHooks)
|
|
const frames = container.querySelectorAll(".text-xs");
|
|
expect(frames.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("hides internal frames when showInternalFrames is false", () => {
|
|
const { container } = render(
|
|
<StackTrace defaultOpen trace={sampleStackTrace}>
|
|
<StackTraceContent>
|
|
<StackTraceFrames showInternalFrames={false} />
|
|
</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
// Should only have 1 frame (UserList), not the node_modules frames
|
|
const frames = container.querySelectorAll(".text-xs");
|
|
expect(frames).toHaveLength(1);
|
|
expect(screen.queryByText(RENDER_WITH_HOOKS_REGEX)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(BEGIN_WORK_REGEX)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("calls onFilePathClick when file path is clicked", async () => {
|
|
const onFilePathClick = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<StackTrace
|
|
defaultOpen
|
|
onFilePathClick={onFilePathClick}
|
|
trace={simpleStackTrace}
|
|
>
|
|
<StackTraceContent>
|
|
<StackTraceFrames />
|
|
</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
const filePathButton = screen.getByText("/src/index.ts:10:5");
|
|
await user.click(filePathButton);
|
|
|
|
expect(onFilePathClick).toHaveBeenCalledWith("/src/index.ts", 10, 5);
|
|
});
|
|
|
|
it("shows no frames message when empty", () => {
|
|
const emptyTrace = "Error: test";
|
|
|
|
render(
|
|
<StackTrace defaultOpen trace={emptyTrace}>
|
|
<StackTraceContent>
|
|
<StackTraceFrames />
|
|
</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(screen.getByText("No stack frames")).toBeInTheDocument();
|
|
});
|
|
|
|
it("handles node: internal paths", () => {
|
|
render(
|
|
<StackTrace defaultOpen trace={nodeInternalTrace}>
|
|
<StackTraceContent>
|
|
<StackTraceFrames showInternalFrames={false} />
|
|
</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
// node: paths should be hidden
|
|
expect(screen.queryByText(NODE_FS_REGEX)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("stack trace parsing", () => {
|
|
it("parses TypeError correctly", () => {
|
|
render(
|
|
<StackTrace trace={sampleStackTrace}>
|
|
<StackTraceErrorType />
|
|
<StackTraceErrorMessage />
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(screen.getByText("TypeError")).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText("Cannot read properties of undefined (reading 'map')")
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("parses simple Error correctly", () => {
|
|
render(
|
|
<StackTrace trace={simpleStackTrace}>
|
|
<StackTraceErrorType />
|
|
<StackTraceErrorMessage />
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(screen.getByText("Error")).toBeInTheDocument();
|
|
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
|
});
|
|
|
|
it("handles frames without function names", () => {
|
|
const traceWithoutFn = `Error: test
|
|
at /src/index.ts:10:5`;
|
|
|
|
render(
|
|
<StackTrace defaultOpen trace={traceWithoutFn}>
|
|
<StackTraceContent>
|
|
<StackTraceFrames />
|
|
</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(screen.getByText("/src/index.ts:10:5")).toBeInTheDocument();
|
|
});
|
|
|
|
it("handles async function names", () => {
|
|
const asyncTrace = `Error: test
|
|
at async fetchData (/src/api.ts:20:10)`;
|
|
|
|
render(
|
|
<StackTrace defaultOpen trace={asyncTrace}>
|
|
<StackTraceContent>
|
|
<StackTraceFrames />
|
|
</StackTraceContent>
|
|
</StackTrace>
|
|
);
|
|
|
|
expect(screen.getByText(ASYNC_FETCH_DATA_REGEX)).toBeInTheDocument();
|
|
});
|
|
});
|