Test Results

Mostre a execução de testes de um agente enquanto ela acontece. Os testes chegam em seus arquivos e depois se recolhem em “118 passed, 2 failed in 4.2s” com cada falha ainda aberta. Diffs de Expected e Received por palavra, timeouts e snapshots, stacks do Stack Trace, filtros, executar falhas de novo e pedir para corrigir, além de adaptadores para Vitest, Jest, Playwright e pytest.

Quando um agente roda seus testes, o chat deveria mostrar o que um desenvolvedor procura: quanto já foi, o que falhou e por quê. Enquanto os testes rodam, Test Results mostra uma linha ao vivo “Running tests · 42 of 120” com contadores que avançam dígito a dígito e uma barra dividida em aprovados, com falha e pulados. Os testes chegam aos seus arquivos à medida que terminam, e o arquivo em andamento fica aberto.

Quando a execução termina, a linha vira um resumo no passado como “118 passed, 2 failed in 4.2s”. Uma execução limpa se recolhe. Uma com falhas fica aberta, com os arquivos aprovados recolhidos e os com falha abertos, a menos que você mesmo os tenha aberto ou fechado. Cada falha mostra sua mensagem, um diff de Expected e Received por palavra e seu stack via Stack Trace, com seus frames primeiro e os do framework recolhidos. Timeouts e divergências de snapshot têm texto próprio.

Filtre por All, Failed, Skipped ou Slow, copie uma falha como texto, abra frames no seu editor e envie Ask to fix, Rerun failed, Rerun e Update snapshot de volta ao agente. fromVitestJson, fromJestJson, fromPlaywrightJson e fromJUnitXml transformam relatórios em uma execução, e applyTestEvent junta eventos ao vivo em uma só.

  1. Obtenha o HextaUI Pro

    Um único pagamento libera todos os blocos, incluindo os futuros. Ver preços.

  2. Adicione o registro Pro ao components.json

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

    Crie um token na sua página de conta e coloque-o em .env.local como HEXTAUI_PRO_TOKEN.

  4. Adicione o bloco

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

Com o AI SDK

Faça de runTests uma ferramenta cujo execute entrega a execução após cada evento do reporter, para que cada atualização chegue como saída preliminar. Renderize dentro de um ToolCall que fica aberto quando testes falham, e envie Ask to fix e Rerun failed de volta ao agente como mensagens.

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
  )
}

De um relatório salvo

fromVitestJson e fromJestJson leem a saída do reporter JSON. Passe uma root para encurtar caminhos absolutos, e getEditorHref para abrir frames no Cursor ou no 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 e XML do JUnit

fromJUnitXml lê pytest --junitxml e outros relatórios JUnit. Classes de teste viram blocos describe, o código que falhou aparece como código, e Rerun failed entrega os 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("::")
          )
        )
      }
    />
  )
}

De seus próprios eventos

Envie eventos run-start, test-start, test-end, file-end e run-end de qualquer runner e junte-os com applyTestEvent. Ela é pura, então funciona em um reducer, em um store ou no servidor.

"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} />
}

Anatomia

As partes que você compõe, de fora para dentro.

ParteDescrição
TestResultsA execução: cabeçalho ao vivo, progresso, filtro, ações e arquivos.
ValueDiffExpected e Received, com as palavras alteradas marcadas.
applyTestEvent, createTestRunFunções puras que montam uma execução a partir de eventos em streaming.
fromVitestJson, fromJestJson, fromPlaywrightJson, fromJUnitXmlAdaptadores que transformam a saída de um reporter em uma execução.
summarizeRun, formatFailuresA linha de resumo, e as falhas como texto simples para um prompt ou a área de transferência.

TestResults

Também aceita todas as props de section, exceto children.

PropTipoPadrão
runA execução, de um adaptador, de applyTestEvent ou dos seus próprios dados.
TestRun–
titleO rótulo ao vivo enquanto os testes rodam.
string"Running tests"
labelNome acessível da região.
string"Test results"
slowThresholdTestes que levam pelo menos esse número de milissegundos contam como lentos.
number300
rootRaiz do projeto, removida dos caminhos nos stacks.
string–
defaultOpenSubstitui se a execução está aberta. Por padrão ela fica aberta enquanto roda e quando algo falhou.
boolean–
defaultFilterO filtro inicial.
"all" | "failed" | "skipped" | "slow""all"
onAskToFixMostra Ask to fix na execução e em cada falha.
(failed: FailedTest[]) => void–
onRerunFailedMostra Rerun failed quando a execução termina com falhas.
(failed: FailedTest[]) => void–
onRerunTestMostra Rerun em cada falha.
(failed: FailedTest) => void–
onUpdateSnapshotMostra Update snapshot nas divergências de snapshot.
(failed: FailedTest) => void–
getFrameHrefTransforma frames do stack em links, por exemplo com getEditorHref.
(frame: StackFrame) => string | undefined–
onFrameClickChamada quando um frame é clicado.
(frame: StackFrame, event: MouseEvent) => void–
PropTipoPadrão
statusSe ainda há testes rodando.
"running" | "done" | "cancelled"–
filesArquivos na ordem em que começaram, cada um com seus testes, duração e um erro se não carregou.
TestFile[]–
totalQuantos testes esperar, para “42 of 120”. Por padrão, as contagens coletadas por arquivo.
number–
durationMilissegundos, para o resumo. Medido durante a execução quando omitido.
number–
startedAtQuando a execução começou, como timestamp, para o cronômetro ao vivo.
number–
PropTipoPadrão
nameO nome do próprio teste.
string–
suiteBlocos describe que o envolvem, do mais externo para o mais interno.
string[]–
statusO estado do teste.
"queued" | "running" | "passed" | "failed" | "skipped" | "todo"–
durationMilissegundos.
number–
retriesQuantas vezes ele foi repetido.
number–
failurePor que ele falhou.
TestFailure–
PropTipoPadrão
kindEscolhe o texto, como “Timed out after 5s” ou “Snapshot mismatch”.
"assertion" | "snapshot" | "timeout" | "error"–
messageA mensagem da asserção, sem as linhas Expected e Received.
string–
expected, receivedOs dois lados da comparação, lidos do erro, de um diff ou da mensagem.
string–
stackTexto para parseStackTrace. Omitido quando não há frames.
string–
detailsSaída extra, como o código-fonte que falhou no pytest.
string–
rawO texto original, usado pelo Copy.
string–

applyTestEvent(run, event)

Retorna uma nova execução. createTestRun() dá uma vazia para começar.

PropTipoPadrão
eventUm de run-start, file-start, test-start, test-end, file-end e run-end. Os testes são identificados por id, ou por arquivo, suite e nome.
TestEvent–
event.errorUma string, ou um objeto com message, stack, expected, actual e diff, como o TestError do Vitest.
TestErrorInput–

fromVitestJson(report, options)

fromJestJson recebe o mesmo relatório. fromPlaywrightJson lê o reporter JSON do Playwright, e fromJUnitXml(xml, options) lê XML do JUnit.

PropTipoPadrão
options.rootRaiz do projeto a remover dos caminhos de arquivo. Sem ela, os caminhos começam na primeira pasta, como src ou tests.
string–
TeclaAção
TabPercorre o cabeçalho da execução, o filtro, as ações, cada arquivo e cada teste que falhou.
EnterSpaceAbre ou fecha a execução, um arquivo ou uma falha.
←→Alterna entre os filtros.
  • A execução é uma região com nome e aria-busy enquanto os testes rodam. Uma região ao vivo educada anuncia o resumo final uma única vez, então testes chegando e contadores avançando ficam em silêncio.
  • O cabeçalho da execução, cada arquivo e cada teste que falhou são botões de divulgação com aria-expanded. Seus nomes leem o resumo, as contagens do arquivo ou o status e o nome do teste.
  • O filtro é um grupo de toggles. As setas alternam entre as opções, e filtros vazios ficam desativados.
  • O status nunca é só cor: cada estado tem sua própria marca, leitores de tela ouvem “Passed” ou “Failed” antes de cada teste, Expected e Received têm rótulo e as palavras alteradas ficam sublinhadas em cores forçadas. Com movimento reduzido, linhas e marcas aparecem sem deslizar.

Construído com

Os componentes gratuitos do HextaUI de que Test Results é feito. Cada um é instalado separadamente.

Código

7 arquivos, adicionados a components/blocks/test-results.