HextaUI

Test Results

Show an agent’s test run as it happens. Tests stream into their files, then fold into “118 passed, 2 failed in 4.2s” with every failure still open. Word-level Expected and Received diffs, timeouts and snapshots, stacks from Stack Trace, filters, Rerun failed and Ask to fix, plus adapters for Vitest, Jest, Playwright and pytest.

When an agent runs your tests, the chat should show what a developer looks for: how far along it is, what failed and why. While tests run, Test Results shows a live “Running tests · 42 of 120” line with counts that tick digit by digit and a bar split into passed, failed and skipped. Tests stream into their files as they finish, and the file that’s running stays open.

When the run ends, the line turns into a past-tense summary such as “118 passed, 2 failed in 4.2s”. A clean run folds away. A failing one stays open with passing files folded and failing files open, unless you opened or closed them yourself. Each failure shows its message, a word-level Expected and Received diff and its stack through Stack Trace, with your frames first and framework frames folded. Timeouts and snapshot mismatches get their own wording.

Filter by All, Failed, Skipped or Slow, copy a failure as text, open frames in your editor, and send Ask to fix, Rerun failed, Rerun and Update snapshot back to the agent. fromVitestJson, fromJestJson, fromPlaywrightJson and fromJUnitXml turn reports into a run, and applyTestEvent folds live events into one.

  1. Get HextaUI Pro

    One payment unlocks every block, including future ones. See pricing.

  2. Add the Pro registry to components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Add your token

    Create a token on your account page and put it in .env.local as HEXTAUI_PRO_TOKEN.

  4. Add the block

    pnpm dlx shadcn@latest add @hextaui-pro/test-results

With the AI SDK

Make runTests a tool whose execute yields the run after every reporter event, so each update arrives as a preliminary output. Render it inside a ToolCall that stays open when tests fail, and send Ask to fix and Rerun failed back to the agent as messages.

import { useChat } from "@ai-sdk/react"
import { isToolUIPart, tool, type ToolUIPart, type UIMessage } from "ai"
import { startVitest, type Reporter, type TestCase } from "vitest/node"
import { z } from "zod"

import { getEditorHref } from "../stack-trace/parse-stack-trace"
import { ToolCall } from "../tool-calls/tool-call"
import { getToolPartStatus } from "../tool-calls/tool-part"
import { TestResults } from "@/components/blocks/test-results/test-results"
import {
  applyTestEvent,
  createTestRun,
  formatFailures,
  formatRunForModel,
  getFailedTests,
  testKey,
  type TestEvent,
  type TestRun,
} from "@/components/blocks/test-results/test-run"

function suiteOf(test: TestCase) {
  const names: string[] = []
  for (let parent = test.parent; parent.type === "suite"; parent = parent.parent) {
    names.unshift(parent.name)
  }
  return names
}

export const runTests = tool({
  description:
    "Run the project's Vitest tests. Pass files or a test name pattern to run only some of them, for example the ones that failed.",
  inputSchema: z.object({
    files: z.array(z.string()).optional(),
    testNamePattern: z.string().optional(),
  }),
  execute: async function* ({ files = [], testNamePattern }, { abortSignal }) {
    const queue: TestEvent[] = []
    let finished = false
    let wake = () => {}
    const push = (event: TestEvent) => {
      queue.push(event)
      wake()
    }
    const describe = (test: TestCase) => ({
      file: test.module.relativeModuleId,
      suite: suiteOf(test),
      name: test.name,
      id: testKey(test.module.relativeModuleId, suiteOf(test), test.name),
    })
    const reporter: Reporter = {
      onTestRunStart: () => push({ type: "run-start", at: Date.now() }),
      onTestModuleCollected: (module) =>
        push({
          type: "file-start",
          file: module.relativeModuleId,
          total: [...module.children.allTests()].length,
        }),
      onTestCaseReady: (test) => push({ type: "test-start", ...describe(test) }),
      onTestCaseResult: (test) => {
        const result = test.result()
        push({
          type: "test-end",
          ...describe(test),
          status: result.state === "pending" ? "skipped" : result.state,
          duration: test.diagnostic()?.duration,
          retries: test.diagnostic()?.retryCount,
          error: result.errors?.[0],
        })
      },
      onTestModuleEnd: (module) =>
        push({
          type: "file-end",
          file: module.relativeModuleId,
          duration: module.diagnostic().duration,
          error: module.errors()[0],
        }),
      onTestRunEnd: (_modules, _errors, reason) => {
        push({ type: "run-end", at: Date.now(), cancelled: reason === "interrupted" })
        finished = true
      },
    }

    const vitest = await startVitest("test", files, {
      watch: false,
      reporters: [reporter],
      testNamePattern,
    })
    abortSignal?.addEventListener("abort", () => {
      void vitest.cancelCurrentRun("keyboard-input")
    })

    let run: TestRun = createTestRun({
      command: ["vitest run", ...files].join(" "),
    })
    while (!finished || queue.length) {
      if (!queue.length) {
        await new Promise<void>((resolve) => (wake = resolve))
      }
      while (queue.length) run = applyTestEvent(run, queue.shift()!)
      yield run
    }
    await vitest.close()
  },
  toModelOutput: ({ output }) => ({ type: "text", value: formatRunForModel(output) }),
})

function TestRunCall({
  part,
  streaming,
  onSend,
}: {
  part: ToolUIPart
  streaming: boolean
  onSend: (text: string) => void
}) {
  const run = part.state === "output-available" ? (part.output as TestRun) : undefined
  const failed = run ? getFailedTests(run) : []
  const input = (part.input ?? {}) as { files?: string[] }

  return (
    <ToolCall
      id={part.toolCallId}
      kind="run"
      subject={["vitest run", ...(input.files ?? [])].join(" ")}
      status={getToolPartStatus(part, { stopped: !streaming })}
      exitCode={run && run.status !== "running" ? (failed.length ? 1 : 0) : undefined}
      content={
        run ? (
          <TestResults
            run={run}
            getFrameHref={(frame) =>
              getEditorHref(frame, { root: process.env.NEXT_PUBLIC_PROJECT_ROOT })
            }
            onAskToFix={(tests) =>
              onSend(`Fix these failing tests:\n\n${formatFailures(tests)}`)
            }
            onRerunFailed={(tests) =>
              onSend(
                `Rerun only these tests: ${[...new Set(tests.map((test) => test.file))].join(", ")}`
              )
            }
            onRerunTest={({ file, test }) =>
              onSend(`Rerun “${test?.name}” in ${file}.`)
            }
          />
        ) : undefined
      }
    />
  )
}

function AssistantMessage({
  message,
  streaming,
  onSend,
}: {
  message: UIMessage
  streaming: boolean
  onSend: (text: string) => void
}) {
  return message.parts.map((part, index) =>
    isToolUIPart(part) && part.type === "tool-runTests" ? (
      <TestRunCall
        key={part.toolCallId}
        part={part}
        streaming={streaming}
        onSend={onSend}
      />
    ) : part.type === "text" ? (
      <p key={index}>{part.text}</p>
    ) : null
  )
}

export function Chat() {
  const { messages, status, sendMessage } = useChat()
  const busy = status === "submitted" || status === "streaming"

  return messages.map((message, index) =>
    message.role === "assistant" ? (
      <AssistantMessage
        key={message.id}
        message={message}
        streaming={busy && index === messages.length - 1}
        onSend={(text) => sendMessage({ text })}
      />
    ) : null
  )
}

From a saved report

fromVitestJson and fromJestJson read the JSON reporter’s output. Pass a root to trim absolute paths, and getEditorHref to open frames in Cursor or VS Code.

"use client"

import { getEditorHref } from "../stack-trace/parse-stack-trace"
import { fromVitestJson, type VitestJsonReport } from "@/components/blocks/test-results/adapters"
import { TestResults } from "@/components/blocks/test-results/test-results"
import { formatFailures } from "@/components/blocks/test-results/test-run"

export function SavedRun({
  report,
  onSend,
}: {
  report: VitestJsonReport
  onSend: (text: string) => void
}) {
  const root = "/Users/me/acme"
  const run = fromVitestJson(report, { root })

  return (
    <TestResults
      run={run}
      root={root}
      getFrameHref={(frame) => getEditorHref(frame, { editor: "cursor", root })}
      onAskToFix={(failed) =>
        onSend(`Fix these failing tests:\n\n${formatFailures(failed)}`)
      }
    />
  )
}

pytest and JUnit XML

fromJUnitXml reads pytest --junitxml and other JUnit reports. Test classes become describe blocks, the failing source shows as code, and Rerun failed hands you the node ids.

"use client"

import { fromJUnitXml } from "@/components/blocks/test-results/adapters"
import { TestResults } from "@/components/blocks/test-results/test-results"

export function PytestRun({
  xml,
  onRerun,
}: {
  xml: string
  onRerun: (nodeIds: string[]) => void
}) {
  const run = fromJUnitXml(xml)

  return (
    <TestResults
      run={run}
      title="Running pytest"
      slowThreshold={1000}
      onRerunFailed={(failed) =>
        onRerun(
          failed.map(({ file, test }) =>
            [file, ...(test?.suite ?? []), test?.name].filter(Boolean).join("::")
          )
        )
      }
    />
  )
}

From your own events

Send run-start, test-start, test-end, file-end and run-end events from any runner and fold them with applyTestEvent. It’s pure, so it works in a reducer, a store or on the server.

"use client"

import * as React from "react"

import { TestResults } from "@/components/blocks/test-results/test-results"
import {
  applyTestEvent,
  createTestRun,
  type TestEvent,
  type TestRun,
} from "@/components/blocks/test-results/test-run"

export function LiveRun({ url }: { url: string }) {
  const [run, setRun] = React.useState<TestRun>(() => createTestRun())

  React.useEffect(() => {
    const source = new EventSource(url)
    source.onmessage = (message) => {
      const event = JSON.parse(message.data) as TestEvent
      setRun((current) => applyTestEvent(current, event))
      if (event.type === "run-end") source.close()
    }
    return () => source.close()
  }, [url])

  return <TestResults run={run} />
}

Anatomy

The parts you compose, from the outside in.

PartDescription
TestResultsThe run: live header, progress, filter, actions and files.
ValueDiffExpected and received, with the changed words marked.
applyTestEvent, createTestRunPure helpers that build a run from streamed events.
fromVitestJson, fromJestJson, fromPlaywrightJson, fromJUnitXmlAdapters from reporter output to a run.
summarizeRun, formatFailuresThe summary line, and failures as plain text for a prompt or the clipboard.

TestResults

Also accepts every section prop except children.

PropTypeDefault
runThe run, from an adapter, applyTestEvent or your own data.
TestRun–
titleThe live label while tests run.
string"Running tests"
labelAccessible name of the region.
string"Test results"
slowThresholdTests that take at least this many milliseconds count as slow.
number300
rootProject root, stripped from paths in stacks.
string–
defaultOpenOverride whether the run is open. By default it stays open while running and when something failed.
boolean–
defaultFilterThe filter it starts with.
"all" | "failed" | "skipped" | "slow""all"
onAskToFixShows Ask to fix on the run and on each failure.
(failed: FailedTest[]) => void–
onRerunFailedShows Rerun failed once the run ends with failures.
(failed: FailedTest[]) => void–
onRerunTestShows Rerun on each failure.
(failed: FailedTest) => void–
onUpdateSnapshotShows Update snapshot on snapshot mismatches.
(failed: FailedTest) => void–
getFrameHrefTurns stack frames into links, for example with getEditorHref.
(frame: StackFrame) => string | undefined–
onFrameClickCalled when a frame is clicked.
(frame: StackFrame, event: MouseEvent) => void–
PropTypeDefault
statusWhether tests are still running.
"running" | "done" | "cancelled"–
filesFiles in the order they started, each with its tests, duration and an error if it failed to load.
TestFile[]–
totalHow many tests to expect, for “42 of 120”. Defaults to the counts collected per file.
number–
durationMilliseconds, for the summary. Measured while running when left out.
number–
startedAtWhen the run started, as a timestamp, for the live timer.
number–
PropTypeDefault
nameThe test’s own name.
string–
suiteEnclosing describe blocks, outermost first.
string[]–
statusThe test’s state.
"queued" | "running" | "passed" | "failed" | "skipped" | "todo"–
durationMilliseconds.
number–
retriesHow many times it was retried.
number–
failureWhy it failed.
TestFailure–
PropTypeDefault
kindPicks the wording, such as “Timed out after 5s” or “Snapshot mismatch”.
"assertion" | "snapshot" | "timeout" | "error"–
messageThe assertion message, without the Expected and Received lines.
string–
expected, receivedBoth sides of the comparison, read from the error, a diff or the message.
string–
stackText for parseStackTrace. Left out when there are no frames.
string–
detailsExtra output, such as pytest’s failing source.
string–
rawThe original text, used by Copy.
string–

applyTestEvent(run, event)

Returns a new run. createTestRun() gives an empty one to start from.

PropTypeDefault
eventOne of run-start, file-start, test-start, test-end, file-end and run-end. Tests match by id, or by file, suite and name.
TestEvent–
event.errorA string, or an object with message, stack, expected, actual and diff, like Vitest’s TestError.
TestErrorInput–

fromVitestJson(report, options)

fromJestJson takes the same report. fromPlaywrightJson reads Playwright’s JSON reporter, and fromJUnitXml(xml, options) reads JUnit XML.

PropTypeDefault
options.rootProject root to strip from file paths. Without it, paths start at the first folder such as src or tests.
string–
KeyAction
TabMoves through the run header, the filter, the actions, each file and each failed test.
EnterSpaceOpens or closes the run, a file or a failure.
←→Moves between filters.
  • The run is a labelled region with aria-busy while tests run. A polite live region announces the final summary once, so streaming tests and ticking counts stay quiet.
  • The run header, each file and each failed test are disclosure buttons with aria-expanded. Their names read the summary, the file’s counts, or the test’s status and name.
  • The filter is a toggle group. Arrow keys move between options, and filters with nothing in them are disabled.
  • Status is never color alone: each state has its own mark, screen readers hear “Passed” or “Failed” before each test, Expected and Received are labelled, and changed words are underlined in forced colors. With reduced motion, rows and marks appear without sliding.

Built with

The free HextaUI components Test Results is made from. Each one installs on its own.

Code

7 files, added to components/blocks/test-results.