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.
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
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 phasefireEvent.click(screen.getByRole('button', { name: /stop/i }));expect(screen.queryByText('thinking…')).not.toBeInTheDocument(); // stoppedexpect(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:
# Fail CI when copied components drift from the registrynpx slipstream-ui@latest diff # exit 1 on driftnpx slipstream-ui@latest diff --json # machine-readable# And validate every COMPONENT_MANIFEST in the repopnpm validate:manifests # schema + TS↔manifest drift
# .github/workflows/ci.yml- name: Slipstream driftrun: 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:
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)
- 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.