createReactTestUtils()
Contract-bound fixtures for tool cards and chat pages. No agent runtime.
createReactTestUtils is the usual import. It returns card fixtures (toolProps), a mounted chat harness (createClientHarness), plus the same t.model helpers as createTestUtils().
There is no PAI runtime behind it. You script the thread state a server would have sent. Waiting cards, a mounted chat, and client tools are under Examples.
import { createReactTestUtils } from "@pai/react-test-utils";
import { Assistant } from "./pai";
const t = createReactTestUtils(Assistant);
render(
<SearchDocsCard
tool={t.toolProps("searchDocs").outputAvailable({
input: { query: "tenancy" },
output: { hits: [{ title: "Identity", url: "/guides/identity" }] },
})}
/>,
);The package also exports createClientToolHarness() and TestChatUI for a canned chat shell.
Import
import {
createReactTestUtils,
createClientToolHarness,
TestChatUI,
} from "@pai/react-test-utils";Signature
function createReactTestUtils(
binding: PaiAgentBinding | { Pai; AI },
): ReactContractTestUtils;Parameters
binding: PaiAgentBinding | { Pai, AI }
Usually Pai.agent("main"). Pass { Pai, AI } only for a custom binding.
Returns
ReactContractTestUtils
Includes createTestUtils() (t.model.queue, t.getTool, t.waitForTool, …).
toolProps: (name) => BoundToolRenderPropsFactory
Build ToolRenderProps for one card. No providers required.
inputStreaming: (options?: { input? }) => ToolRenderProps
Arguments still arriving.
inputAvailable: (options: { input }) => ToolRenderProps
Arguments complete, not running.
running: (options?: { input? }) => ToolRenderProps
Execute in flight.
waiting: (options: { input?, action }) => ToolRenderProps
Suspended. tool.action.submit calls action.onSubmit.
action: { name, input, onSubmit? }
name: string
input: suspend payload
onSubmit?: (resume) => void
outputAvailable: (options: { input?, output }) => ToolRenderProps
Done.
outputError: (options: { input?, error }) => ToolRenderProps
Failed. error is Error | string.
cancelled: (options?: { input? }) => ToolRenderProps
Stopped.
createClientHarness: (options?) => CreateClientHarnessResult
Real Pai.Provider + ThreadProvider over a fake transport. Use
await using.
options?: object
threadId?: string
clientData?: TContract['clientData']
toolRenderers?: ToolRendererMap
observe?: boolean
Provider: (props: { children? }) => ReactElement
Wrap the UI under test.
driver: ClientHarnessDriver
send: Thread['send']
Same as the live client.
responses.queue: (response) => Promise<void>
Script the next assistant turn. Builders:
response.assistant([...]), response.text(...),
response.tool(name).result({ input, output }).
events: ClientHarnessEventController
Inject live events (approvals, file updates).
thread: Thread
client: AgentClient
threadId: string
toolRenderers: (map) => map
Identity helper so toolRenderers stays typed in tests.
Also includes message/run factories from createClientHarnessFactories (t.message, t.tool, t.run, t.threadState) for seeding.
createClientToolHarness()
Test one browser tool with no thread.
function createClientToolHarness(definition): ClientToolHarness;execute: (input) => Promise<output>
Run execute and return validated output.
call: (input) => ClientToolCall
Pending call whose renderer and executor share action/ToolData. Execution is explicit.
View: ComponentType
Mount the tool renderer.
execute: () => Promise<output>
Drive automatic execution.
action: PendingActionView
action.submit completes a render-only tool.
render: ClientToolRenderFixtures
Static lifecycle fixtures. Same names as toolProps
(inputStreaming, outputAvailable, outputError, …).
Examples
Approval card
waiting builds tool.action. The card still calls tool.action.submit. onSubmit is the spy the fixture runs when that happens.
const onSubmit = vi.fn();
render(
<PublishCard
tool={t.toolProps("publishPost").waiting({
input: { draftId: "draft_1" },
action: {
name: "approve",
input: { title: "Tenancy", summary: "Publish the identity guide." },
onSubmit,
},
})}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
expect(onSubmit).toHaveBeenCalledWith({ approved: true });Other card states
const searchDocs = t.toolProps("searchDocs");
render(
<SearchDocsCard
tool={searchDocs.inputStreaming({ input: { query: "ten" } })}
/>,
);
render(
<SearchDocsCard
tool={searchDocs.outputError({
input: { query: "tenancy" },
error: "offline",
})}
/>,
);
render(
<SearchDocsCard
tool={searchDocs.cancelled({ input: { query: "tenancy" } })}
/>,
);Chat page
createClientHarness mounts the real Pai.Provider and ThreadProvider over a fake transport. Queue the assistant turn, then send like the UI would. Use await using. Use TestChatUI instead of your Chat when you only need a composer and transcript.
await using harness = t.createClientHarness({ threadId: "thread-1" });
render(
<harness.Provider>
<Chat />
</harness.Provider>,
);
await harness.driver.responses.queue((response) =>
response.assistant([response.text("See the identity guide.")]),
);
const run = await harness.driver.send("Where is tenancy documented?");
await run.waitUntilIdle();Tool result, then text:
await harness.driver.responses.queue((response) =>
response.assistant([
response.tool("searchDocs").result({
input: { query: "tenancy" },
output: { hits: [{ title: "Identity", url: "/guides/identity" }] },
}),
response.text("See the identity guide."),
]),
);There is no model and no execute. The harness plays the transcript a real server would have sent.
TestChatUI
Use the canned shell when you only need a composer and transcript.
await using harness = t.createClientHarness({ threadId: "thread-1" });
render(<TestChatUI />, { wrapper: harness.Provider });Client tool
createClientToolHarness tests one browser function with no thread. execute is the usual path when you do not render UI.
import { createClientToolHarness } from "@pai/react-test-utils";
import { z } from "zod";
const getSelection = createClientToolHarness({
name: "getSelection",
description: "Read the current selection",
inputSchema: z.object({}),
outputSchema: z.object({ text: z.string() }),
execute: async () => ({ text: "hello" }),
});
await expect(getSelection.execute({})).resolves.toEqual({ text: "hello" });call starts pending. Mount View, then execute when the test is ready to drive the renderer.
const getSelection = createClientToolHarness({
name: "getSelection",
description: "Read the current selection",
inputSchema: z.object({}),
outputSchema: z.object({ text: z.string() }),
execute: async () => ({ text: "hello" }),
render: ({ part }) =>
part.state === "output-available" ? (
<p>{part.output.text}</p>
) : (
<p>Reading…</p>
),
});
const call = getSelection.call({});
render(<call.View />);
expect(screen.getByText("Reading…")).toBeTruthy();
await act(async () => {
await call.execute();
});
expect(screen.getByText("hello")).toBeTruthy();A render-only tool (no execute) completes through call.action.submit:
await call.action.submit({ approved: true });Static client-tool states
Same getSelection harness as above.
render(
getSelection.render.outputAvailable({
input: {},
output: { text: "hello" },
}),
);
render(
getSelection.render.outputError({
input: {},
error: "offline",
}),
);