Docs

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 includes composer.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