Testing AI components

Three layers keep AI UIs honest: accessibility regressions (jest-axe), interaction behavior (streaming, errors, keyboard), and spec drift (manifests vs TypeScript vs generated JSX). Slipstream ships tooling for all three.

Layer 1 — jest-axe

Every component in the library ships an axe suite; do the same for your compositions. The three states that matter for chat UIs: resting, streaming, and error.

tsx
import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import { describe, expect, it } from 'vitest';
describe('ChatSurface', () => {
it('resting state has no violations', async () => {
const { container } = render(<ChatSurface messages={transcript} />);
expect(await axe(container)).toHaveNoViolations();
});
it('streaming state has no violations', async () => {
const { container } = render(
<ChatSurface messages={transcript} streamingId={transcript.at(-1)!.id} />
);
expect(await axe(container)).toHaveNoViolations();
});
it('error state has no violations', async () => {
const { container } = render(
<ChatSurface messages={transcript} errors={{ 'msg-2': 'Generation failed' }} />
);
expect(await axe(container)).toHaveNoViolations();
});
});

Layer 2 — interaction tests

tsx
import { fireEvent, render, screen } from '@testing-library/react';
it('streams a reply and stops on the stop button', async () => {
render(<ChatSurface />);
const composer = screen.getByRole('textbox');
fireEvent.change(composer, { target: { value: 'Hi' } });
fireEvent.keyDown(composer, { key: 'Enter' });
expect(await screen.findByText('thinking…')).toBeInTheDocument(); // streaming phase
fireEvent.click(screen.getByRole('button', { name: /stop/i }));
expect(screen.queryByText('thinking…')).not.toBeInTheDocument(); // stopped
expect(composer).toHaveValue(''); // composer reset
});
it('renders a retry card when the stream fails', async () => {
// drive your transport's error path (mock fetch / abort)
expect(await screen.findByRole('alert')).toHaveTextContent(/try again/i);
});

jsdom lacks scroll APIs — mock them once in setup (the ai package does: Element.prototype.scrollIntoView = vi.fn(), scrollTo = vi.fn()).

Layer 3 — spec drift in CI

The library enforces manifest ↔ TypeScript sync; your app can enforce the same idea for the components it copies in via the registry:

bash
# Fail CI when copied components drift from the registry
npx slipstream-ui@latest diff # exit 1 on drift
npx slipstream-ui@latest diff --json # machine-readable
# And validate every COMPONENT_MANIFEST in the repo
pnpm validate:manifests # schema + TS↔manifest drift
yaml
# .github/workflows/ci.yml
- name: Slipstream drift
run: npx slipstream-ui@latest diff

Bonus — validate agent-written JSX

If your pipeline lets an agent generate UI, run validate_usage on the result before merge — the same MCP tool, called as a step:

text
mcp: validate_usage({
componentId: "prompt-input",
props: { onSubmitChat: "…", isBusy: true } // <- invented props
})
=> { ok: false, issues: [
{ prop: "onSubmitChat", problem: "unknown", suggestion: "onSubmit" },
{ prop: "isBusy", problem: "unknown", suggestion: "isLoading" }
] }

CI recipe (full)

yaml
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test # jest-axe + interaction suites
- run: pnpm validate:manifests # manifest schema + drift
- run: npx slipstream-ui@latest diff # copied components vs registry

This is exactly what the Slipstream repo runs — see ci.yml.

Was this page helpful?