Test Results

Muestra la ejecución de pruebas de un agente mientras ocurre. Las pruebas llegan a sus archivos y luego se pliegan en “118 passed, 2 failed in 4.2s” con cada fallo todavía abierto. Diffs de Expected y Received por palabra, tiempos de espera y snapshots, stacks de Stack Trace, filtros, volver a ejecutar las fallidas y pedir que lo arregle, además de adaptadores para Vitest, Jest, Playwright y pytest.

Cuando un agente ejecuta tus tests, el chat debería mostrar lo que busca un desarrollador: cuánto lleva, qué falló y por qué. Mientras corren los tests, Test Results muestra una línea en vivo “Running tests · 42 of 120” con contadores que avanzan dígito a dígito y una barra dividida en aprobados, fallidos y omitidos. Los tests llegan a sus archivos al terminar, y el archivo en curso se mantiene abierto.

Cuando termina la ejecución, la línea pasa a un resumen en pasado como “118 passed, 2 failed in 4.2s”. Una ejecución limpia se pliega. Una con fallos sigue abierta, con los archivos que pasan plegados y los que fallan abiertos, salvo que tú los hayas abierto o cerrado. Cada fallo muestra su mensaje, un diff de Expected y Received a nivel de palabra y su stack mediante Stack Trace, con tus frames primero y los del framework plegados. Los timeouts y las diferencias de snapshot tienen su propia redacción.

Filtra por All, Failed, Skipped o Slow, copia un fallo como texto, abre frames en tu editor y envía Ask to fix, Rerun failed, Rerun y Update snapshot de vuelta al agente. fromVitestJson, fromJestJson, fromPlaywrightJson y fromJUnitXml convierten reportes en una ejecución, y applyTestEvent reúne eventos en vivo en una sola.

  1. Obtén HextaUI Pro

    Un solo pago desbloquea todos los bloques, incluidos los futuros. Ver precios.

  2. Añade el registro Pro a components.json

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

    Crea un token en tu página de cuenta y colócalo en .env.local como HEXTAUI_PRO_TOKEN.

  4. Añade el bloque

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

Con AI SDK

Haz de runTests una herramienta cuyo execute entregue la ejecución tras cada evento del reporter, para que cada actualización llegue como salida preliminar. Muéstrala dentro de un ToolCall que sigue abierto cuando fallan tests, y envía Ask to fix y Rerun failed al agente como mensajes.

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

Desde un reporte guardado

fromVitestJson y fromJestJson leen la salida del reporter JSON. Pasa un root para recortar las rutas absolutas, y getEditorHref para abrir frames en Cursor o 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 y XML de JUnit

fromJUnitXml lee pytest --junitxml y otros reportes JUnit. Las clases de test se convierten en bloques describe, el código que falla aparece como código y Rerun failed te da los 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("::")
          )
        )
      }
    />
  )
}

Desde tus propios eventos

Envía eventos run-start, test-start, test-end, file-end y run-end desde cualquier runner y reúnelos con applyTestEvent. Es pura, así que funciona en un reducer, un store o en el 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} />
}

Anatomía

Las partes que compones, de fuera hacia dentro.

ParteDescripción
TestResultsLa ejecución: encabezado en vivo, progreso, filtro, acciones y archivos.
ValueDiffExpected y Received, con las palabras que cambiaron marcadas.
applyTestEvent, createTestRunFunciones puras que construyen una ejecución a partir de eventos en streaming.
fromVitestJson, fromJestJson, fromPlaywrightJson, fromJUnitXmlAdaptadores que convierten la salida de un reporter en una ejecución.
summarizeRun, formatFailuresLa línea de resumen, y los fallos como texto plano para un prompt o el portapapeles.

TestResults

También acepta todas las props de section excepto children.

PropTipoPredeterminado
runLa ejecución, desde un adaptador, applyTestEvent o tus propios datos.
TestRun–
titleLa etiqueta en vivo mientras corren los tests.
string"Running tests"
labelNombre accesible de la región.
string"Test results"
slowThresholdLos tests que tardan al menos estos milisegundos cuentan como lentos.
number300
rootRaíz del proyecto, que se quita de las rutas en los stacks.
string–
defaultOpenAnula si la ejecución está abierta. Por defecto sigue abierta mientras corre y cuando algo falló.
boolean–
defaultFilterEl filtro con el que empieza.
"all" | "failed" | "skipped" | "slow""all"
onAskToFixMuestra Ask to fix en la ejecución y en cada fallo.
(failed: FailedTest[]) => void–
onRerunFailedMuestra Rerun failed cuando la ejecución termina con fallos.
(failed: FailedTest[]) => void–
onRerunTestMuestra Rerun en cada fallo.
(failed: FailedTest) => void–
onUpdateSnapshotMuestra Update snapshot en las diferencias de snapshot.
(failed: FailedTest) => void–
getFrameHrefConvierte los frames del stack en enlaces, por ejemplo con getEditorHref.
(frame: StackFrame) => string | undefined–
onFrameClickSe llama cuando se hace clic en un frame.
(frame: StackFrame, event: MouseEvent) => void–
PropTipoPredeterminado
statusSi todavía hay tests en ejecución.
"running" | "done" | "cancelled"–
filesArchivos en el orden en que empezaron, cada uno con sus tests, su duración y un error si no se pudo cargar.
TestFile[]–
totalCuántos tests se esperan, para “42 of 120”. Por defecto, los recuentos recogidos por archivo.
number–
durationMilisegundos, para el resumen. Se mide durante la ejecución si se omite.
number–
startedAtCuándo empezó la ejecución, como timestamp, para el temporizador en vivo.
number–
PropTipoPredeterminado
nameEl nombre propio del test.
string–
suiteBloques describe que lo contienen, del más externo al más interno.
string[]–
statusEl estado del test.
"queued" | "running" | "passed" | "failed" | "skipped" | "todo"–
durationMilisegundos.
number–
retriesCuántas veces se reintentó.
number–
failurePor qué falló.
TestFailure–
PropTipoPredeterminado
kindElige la redacción, como “Timed out after 5s” o “Snapshot mismatch”.
"assertion" | "snapshot" | "timeout" | "error"–
messageEl mensaje de la aserción, sin las líneas Expected y Received.
string–
expected, receivedLos dos lados de la comparación, leídos del error, de un diff o del mensaje.
string–
stackTexto para parseStackTrace. Se omite cuando no hay frames.
string–
detailsSalida extra, como el código fuente que falla en pytest.
string–
rawEl texto original, usado por Copy.
string–

applyTestEvent(run, event)

Devuelve una ejecución nueva. createTestRun() te da una vacía para empezar.

PropTipoPredeterminado
eventUno de run-start, file-start, test-start, test-end, file-end y run-end. Los tests se identifican por id, o por archivo, suite y nombre.
TestEvent–
event.errorUn string, o un objeto con message, stack, expected, actual y diff, como el TestError de Vitest.
TestErrorInput–

fromVitestJson(report, options)

fromJestJson acepta el mismo reporte. fromPlaywrightJson lee el reporter JSON de Playwright y fromJUnitXml(xml, options) lee XML de JUnit.

PropTipoPredeterminado
options.rootRaíz del proyecto que se quita de las rutas de archivo. Sin ella, las rutas empiezan en la primera carpeta como src o tests.
string–
KeyAcción
TabRecorre el encabezado de la ejecución, el filtro, las acciones, cada archivo y cada test fallido.
EnterSpaceAbre o cierra la ejecución, un archivo o un fallo.
←→Se mueve entre los filtros.
  • La ejecución es una región con nombre y aria-busy mientras corren los tests. Una región en vivo educada anuncia el resumen final una sola vez, así que los tests que llegan y los contadores que avanzan no hacen ruido.
  • El encabezado de la ejecución, cada archivo y cada test fallido son botones de despliegue con aria-expanded. Sus nombres leen el resumen, los recuentos del archivo o el estado y el nombre del test.
  • El filtro es un grupo de toggles. Las flechas se mueven entre las opciones y los filtros vacíos están desactivados.
  • El estado nunca es solo color: cada estado tiene su propia marca, los lectores de pantalla oyen “Passed” o “Failed” antes de cada test, Expected y Received tienen etiqueta y las palabras cambiadas se subrayan en colores forzados. Con movimiento reducido, las filas y las marcas aparecen sin deslizarse.

Construido con

Los componentes gratuitos de HextaUI con los que está hecho Test Results. Cada uno se instala por separado.

Código

7 archivos, añadidos a components/blocks/test-results.