Client Tool Tests
Test a client-defined tool's browser behavior, ToolData, actions, and renderer without app providers.
Use createClientToolHarness() when the unit under test is one client-defined
tool. The harness starts from the same definition the app registers and does
not need an agent contract, provider, thread, or transport.
Define The Tool Once
A named clientTool() definition keeps schema inference when it lives in its
own module and can be shared by registration and tests:
export const readPage = SupportAI.clientTool({
name: "readPage",
description: "Read text from the current page",
inputSchema: z.object({ selector: z.string() }),
outputSchema: z.object({ text: z.string() }),
execute: ({ input }) => ({
text: document.querySelector(input.selector)?.textContent ?? "",
}),
});Test Execute-Only Behavior
When renderer behavior is irrelevant, the test is one helper plus one call:
import { createClientToolHarness } from "@pai/react-test-utils";
import { readPage } from "./read-page";
it("reads the page", async () => {
document.body.innerHTML = "<main>Current brief</main>";
const tool = createClientToolHarness(readPage);
await expect(tool.execute({ selector: "main" })).resolves.toEqual({
text: "Current brief",
});
});The harness first verifies that the definition's schemas can be serialized for production registration. It then parses input before execution and parses returned output before submitting it. Invalid definitions, fixture inputs, and executor results therefore fail at the corresponding framework boundary.
Drive Renderer And ToolData Together
Use call() when the renderer, execution, action, and ToolData need to share
one lifecycle:
const call = createClientToolHarness(auditPage).call({ selector: "main" });
render(<call.View />);
expect(screen.getByText("Ready to audit")).toBeTruthy();
const execution = call.execute();
expect(await screen.findByText("2 headings")).toBeTruthy();
await expect(execution).resolves.toEqual({ headings: 2 });
expect(call.dataWrites).toMatchObject([
{ channel: "progress", value: { percent: 50 }, transient: true },
{ channel: "progress", value: { percent: 100 }, transient: false },
]);A call starts with a pending output action. Its native tool part is
input-available, while the renderer's runStatus sidecar is waiting.
Its execution.status is "idle", and it does not auto-execute. Starting it
explicitly keeps the initial UI assertion deterministic.
Writes made through data.write() are schema-validated and immediately update
the renderer's selectors. data.list() reads that same live call state, so it
includes the latest transient write just as the mounted production client does.
Use call.dataWrites to distinguish transient from durable writes.
Submit From Renderer UI
Render-only tools can complete through the canonical in-memory output action:
const call = createClientToolHarness(reviewNavigation).call({
destination: "/reports",
});
render(<call.View />);
await user.click(
screen.getByRole("button", { name: "Approve navigation" }),
);
expect(call.output).toEqual({ approved: true });The renderer receives the same call.action exposed to the test. Submission
produces an output-available part. Failure and cancellation produce an
output-error part; the latter is exposed to the renderer with the derived
cancelled display state.
Render A Static State
Use the render fixtures when a presentation case does not need a driven
lifecycle:
render(
tool.render.outputError({
input: { selector: "main" },
data: { progress: [{ label: "Scanning headings", percent: 75 }] },
error: new Error("Page unavailable"),
}),
);The static renderer still runs inside a React component, so the tool's render callback can use hooks. Input is schema-parsed as it is in production. Completed output and ToolData fixtures represent already-parsed persisted values, so the harness checks that they remain JSON-compatible without applying mutating schemas a second time.
Name-Less Map Configs
If the definition omits name because it is a clientTools map value, supply
the registration key to the harness:
const readPageConfig = SupportAI.clientTool({
description,
inputSchema: input,
outputSchema: output,
execute,
});
const tool = createClientToolHarness("readPage", readPageConfig);Use a full client harness instead when the behavior depends on provider scope, thread state, registrations across a mounted app, or transport events.