React UI
Testing React UI
Test route contracts, fake streams, component states, and human-input flows.
Test the boundary in layers: route contract, stream parsing, component states, and product-specific rendering.
Route contract
A React-facing route should accept UIStreamRequest and return a JSONL or SSE stream.
import { describe, expect, it } from "vitest";
describe("POST /api/chat", () => {
it("accepts the React UI request shape", async () => {
const response = await POST(
new Request("http://test.local/api/chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
messages: [{ role: "user", content: "Hello" }],
stream: true,
}),
}),
);
expect(response.headers.get("content-type")).toContain("application/x-ndjson");
});
});
Fake direct transport
Use createDirectTransport(...) when the test should avoid fetch and server routes.
import { createDirectTransport, useChat } from "@anvia/react";
import type { UIStreamEvent, UIStreamRequest } from "@anvia/react";
const transport = createDirectTransport<UIStreamRequest, UIStreamEvent>(async function* () {
yield {
type: "text_delta",
messageId: "assistant-1",
partId: "text-1",
delta: "Hello",
};
});
function TestChat() {
const chat = useChat({ transport });
return <ChatProvider controller={chat}>{/* render test UI */}</ChatProvider>;
}
Component state checks
Prefer visible behavior over implementation details:
- submit is disabled while streaming
- stop is enabled while streaming
- errors render in
Thread.Error - user messages align with
data-role="user" - tool cards render
data-state="output-available"after a result - attachment names truncate instead of resizing the composer
- image previews render loading/error states and image actions disable without a source
- selected text inside one message shows
SelectionToolbar, while cross-message selections do not - quoted selections update controlled composer quote state and submit quote metadata
- composer triggers open from
@,/, or$, keyboard selection inserts an entity, and submit metadata includescomposer.entities - thread-list triggers call the app-owned controller and keyboard focus moves through item triggers
For richer chat surfaces, cover the behavior users rely on rather than every internal context
field. Composer.Input is a rich contenteditable editor, so test it through visible typing and
submitted state rather than textarea-only value assertions. The React UI package tests include
focused coverage for Image, SelectionToolbar, controlled Composer.Quote, trigger entities,
and ThreadList controller actions.
Composer trigger tests
For mentions, slash commands, and variables, render Composer.Root with deterministic trigger
items and assert the user-facing behavior:
const transport = createDirectTransport(async function* () {});
function TestChat() {
const chat = useChat({ transport });
return (
<ChatProvider controller={chat}>
<Composer.Root
triggers={[
{
id: "people",
char: "@",
items: [{ id: "user_ada", label: "Ada Lovelace" }],
},
]}
>
<Composer.Input />
<Composer.TriggerMenu />
<Composer.Submit />
</Composer.Root>
</ChatProvider>
);
}
render(<TestChat />);
await user.type(screen.getByRole("textbox", { name: "Message" }), "@ada");
await user.click(screen.getByRole("option", { name: "Ada Lovelace" }));
await user.click(screen.getByRole("button", { name: "Send" }));
When the app controls entities or uses submitMessage, assert that the submitted entity has the
expected id, triggerId, trigger, text, and data payload.
Images, selection, and thread lists
When a message renderer branches on image attachments, test that the image primitive can read the attachment context and that fallback rendering still works for non-image files.
render(
<Message.Attachment>
<Image.Root>
<Image.Preview loadingFallback="Loading" errorFallback="Broken" />
<Image.Name />
</Image.Root>
</Message.Attachment>,
);
For selection quotes, create a real DOM selection inside one Message.Root, dispatch
selectionchange, and assert that SelectionToolbar.Quote passes { text, messageId } into
controlled composer state.
For thread lists, use a fake ThreadListController with vi.fn() action methods and assert that
ThreadListItem.Trigger, Archive, Unarchive, and Delete call the expected controller method.
Human-input tests
When testing approvals or questions, feed the hook stream events and assert that the panel appears.
const approvalEvent = {
type: "tool_approval_request",
approval: {
id: "approval_1",
toolName: "issue_refund",
args: "{\"amountCents\":7500}",
status: "pending",
},
};
Then test the decision handler separately:
await chat.approveTool("approval_1", "Approved by support lead.");
expect(decideApproval).toHaveBeenCalledWith(
expect.objectContaining({
approvalId: "approval_1",
approved: true,
}),
);
Build validation
For docs changes, run:
pnpm --filter www reference-check
pnpm --filter www build
For package behavior changes, also run:
pnpm --filter @anvia/react typecheck
pnpm --filter @anvia/react-ui typecheck
pnpm --filter @anvia/react-ui test