React Tests
Cards with toolProps. Chat pages with createClientHarness. No runtime.
React tests prove UI. They do not run an agent. You either pass renderer props for one card, or script public thread state for a chat page.
pnpm add -D @pai/react-test-utilsimport { createReactTestUtils } from "@pai/react-test-utils";
import { Assistant } from "./pai";
const t = createReactTestUtils(Assistant);createReactTestUtils takes the agent binding (Pai.agent("main")). It includes the same t.model helpers as @pai/test-utils, plus toolProps and createClientHarness.
One tool card
const searchDocs = t.toolProps("searchDocs");
render(
<SearchDocsCard
tool={searchDocs.outputAvailable({
input: { query: "tenancy" },
output: { hits: [{ title: "Identity", url: "/guides/identity" }] },
})}
/>,
);| Factory | Tool part state |
|---|---|
inputStreaming({ input? }) | Arguments still arriving |
inputAvailable({ input }) | Arguments complete, not running |
running({ input? }) | Execute in flight |
waiting({ input, action }) | Suspended; tool.action is set |
outputAvailable({ input, output }) | Done |
outputError({ input, error }) | Failed |
cancelled({ input? }) | Stopped |
Use waiting for approval cards. Pass action: { name, input, onSubmit } to build the fixture. 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 });Failed and cancelled cards use outputError({ input, error }) and cancelled({ input? }).
Chat page
createClientHarness mounts the real Pai.Provider and ThreadProvider over a fake transport. You queue assistant responses, then send like the UI would.
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 results:
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.
Client tools
Test the browser function in isolation with createClientToolHarness. There is no thread.
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" });That is a client-tool test, not a runtime test. The browser function is real; the agent is not. Mount the same clientTools on Pai.agent("main", { clientTools }) in the product — see Client tools.
Next
- Tool UI
- Runtime tests when the bug is PAI, not React