Test Results

Affichez l’exécution des tests d’un agent en direct. Les tests arrivent dans leurs fichiers, puis se replient en « 118 passed, 2 failed in 4.2s » avec chaque échec toujours ouvert. Diffs Expected et Received au mot près, délais dépassés et snapshots, stacks de Stack Trace, filtres, relance des échecs et demande de correction, plus des adaptateurs pour Vitest, Jest, Playwright et pytest.

Quand un agent lance vos tests, le chat devrait montrer ce qu’un développeur cherche : où il en est, ce qui a échoué et pourquoi. Pendant l’exécution, Test Results affiche une ligne en direct « Running tests · 42 of 120 » avec des compteurs qui défilent chiffre par chiffre et une barre partagée entre réussis, échoués et ignorés. Les tests arrivent dans leurs fichiers à mesure qu’ils se terminent, et le fichier en cours reste ouvert.

Quand l’exécution se termine, la ligne devient un résumé au passé comme « 118 passed, 2 failed in 4.2s ». Une exécution sans échec se replie. Une exécution en échec reste ouverte, avec les fichiers réussis repliés et les fichiers en échec ouverts, sauf si vous les avez ouverts ou fermés vous-même. Chaque échec affiche son message, un diff Expected et Received au niveau des mots et son stack via Stack Trace, avec vos frames en premier et ceux du framework repliés. Les timeouts et les différences de snapshot ont leur propre formulation.

Filtrez par All, Failed, Skipped ou Slow, copiez un échec en texte, ouvrez les frames dans votre éditeur et renvoyez Ask to fix, Rerun failed, Rerun et Update snapshot à l’agent. fromVitestJson, fromJestJson, fromPlaywrightJson et fromJUnitXml transforment des rapports en exécution, et applyTestEvent assemble des événements en direct en une seule.

  1. Procurez-vous HextaUI Pro

    Un seul paiement débloque tous les blocs, y compris les prochains. Voir les tarifs.

  2. Ajouter le registre Pro à components.json

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

    Créez un token sur votre page de compte et placez-le dans .env.local sous le nom HEXTAUI_PRO_TOKEN.

  4. Ajouter le block

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

Avec AI SDK

Faites de runTests un outil dont l’execute renvoie l’exécution après chaque événement du reporter, pour que chaque mise à jour arrive comme sortie préliminaire. Affichez-la dans un ToolCall qui reste ouvert quand des tests échouent, et renvoyez Ask to fix et Rerun failed à l’agent sous forme de 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
  )
}

Depuis un rapport enregistré

fromVitestJson et fromJestJson lisent la sortie du reporter JSON. Passez une root pour raccourcir les chemins absolus, et getEditorHref pour ouvrir les frames dans Cursor ou 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 et XML JUnit

fromJUnitXml lit pytest --junitxml et les autres rapports JUnit. Les classes de test deviennent des blocs describe, le code source en échec s’affiche comme du code, et Rerun failed vous donne les 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("::")
          )
        )
      }
    />
  )
}

Depuis vos propres événements

Envoyez des événements run-start, test-start, test-end, file-end et run-end depuis n’importe quel runner et assemblez-les avec applyTestEvent. Elle est pure, donc elle fonctionne dans un reducer, un store ou sur le serveur.

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

Anatomie

Les parties à composer, de l’extérieur vers l’intérieur.

PartieDescription
TestResultsL’exécution : en-tête en direct, progression, filtre, actions et fichiers.
ValueDiffExpected et Received, avec les mots modifiés marqués.
applyTestEvent, createTestRunDes fonctions pures qui construisent une exécution à partir d’événements en streaming.
fromVitestJson, fromJestJson, fromPlaywrightJson, fromJUnitXmlDes adaptateurs qui transforment la sortie d’un reporter en exécution.
summarizeRun, formatFailuresLa ligne de résumé, et les échecs en texte brut pour un prompt ou le presse-papiers.

TestResults

Accepte aussi toutes les props de section sauf children.

PropTypePar défaut
runL’exécution, venant d’un adaptateur, d’applyTestEvent ou de vos propres données.
TestRun–
titleLe libellé en direct pendant que les tests tournent.
string"Running tests"
labelNom accessible de la région.
string"Test results"
slowThresholdLes tests qui prennent au moins ce nombre de millisecondes sont considérés comme lents.
number300
rootRacine du projet, retirée des chemins dans les stacks.
string–
defaultOpenForce l’ouverture ou non de l’exécution. Par défaut, elle reste ouverte pendant qu’elle tourne et quand quelque chose a échoué.
boolean–
defaultFilterLe filtre de départ.
"all" | "failed" | "skipped" | "slow""all"
onAskToFixAffiche Ask to fix sur l’exécution et sur chaque échec.
(failed: FailedTest[]) => void–
onRerunFailedAffiche Rerun failed quand l’exécution se termine avec des échecs.
(failed: FailedTest[]) => void–
onRerunTestAffiche Rerun sur chaque échec.
(failed: FailedTest) => void–
onUpdateSnapshotAffiche Update snapshot sur les différences de snapshot.
(failed: FailedTest) => void–
getFrameHrefTransforme les frames du stack en liens, par exemple avec getEditorHref.
(frame: StackFrame) => string | undefined–
onFrameClickAppelée quand on clique sur un frame.
(frame: StackFrame, event: MouseEvent) => void–
PropTypePar défaut
statusSi des tests tournent encore.
"running" | "done" | "cancelled"–
filesLes fichiers dans l’ordre où ils ont démarré, chacun avec ses tests, sa durée et une erreur s’il n’a pas pu se charger.
TestFile[]–
totalLe nombre de tests attendus, pour « 42 of 120 ». Par défaut, les totaux collectés par fichier.
number–
durationMillisecondes, pour le résumé. Mesurée pendant l’exécution si elle est omise.
number–
startedAtLe moment où l’exécution a démarré, en timestamp, pour le minuteur en direct.
number–
PropTypePar défaut
nameLe nom propre du test.
string–
suiteBlocs describe englobants, du plus externe au plus interne.
string[]–
statusL’état du test.
"queued" | "running" | "passed" | "failed" | "skipped" | "todo"–
durationMillisecondes.
number–
retriesCombien de fois il a été relancé.
number–
failurePourquoi il a échoué.
TestFailure–
PropTypePar défaut
kindChoisit la formulation, comme « Timed out after 5s » ou « Snapshot mismatch ».
"assertion" | "snapshot" | "timeout" | "error"–
messageLe message de l’assertion, sans les lignes Expected et Received.
string–
expected, receivedLes deux côtés de la comparaison, lus dans l’erreur, un diff ou le message.
string–
stackTexte pour parseStackTrace. Absent quand il n’y a pas de frames.
string–
detailsSortie supplémentaire, comme le code source en échec de pytest.
string–
rawLe texte d’origine, utilisé par Copy.
string–

applyTestEvent(run, event)

Renvoie une nouvelle exécution. createTestRun() en donne une vide pour commencer.

PropTypePar défaut
eventL’un de run-start, file-start, test-start, test-end, file-end et run-end. Les tests sont reconnus par id, ou par fichier, suite et nom.
TestEvent–
event.errorUne chaîne, ou un objet avec message, stack, expected, actual et diff, comme le TestError de Vitest.
TestErrorInput–

fromVitestJson(report, options)

fromJestJson prend le même rapport. fromPlaywrightJson lit le reporter JSON de Playwright, et fromJUnitXml(xml, options) lit le XML JUnit.

PropTypePar défaut
options.rootRacine du projet à retirer des chemins de fichiers. Sans elle, les chemins commencent au premier dossier comme src ou tests.
string–
ToucheAction
TabParcourt l’en-tête de l’exécution, le filtre, les actions, chaque fichier et chaque test en échec.
EnterSpaceOuvre ou ferme l’exécution, un fichier ou un échec.
←→Passe d’un filtre à l’autre.
  • L’exécution est une région nommée avec aria-busy pendant que les tests tournent. Une région live polie annonce le résumé final une seule fois, pour que les tests qui arrivent et les compteurs qui défilent restent silencieux.
  • L’en-tête de l’exécution, chaque fichier et chaque test en échec sont des boutons de divulgation avec aria-expanded. Leur nom lit le résumé, les totaux du fichier, ou le statut et le nom du test.
  • Le filtre est un groupe de toggles. Les flèches passent d’une option à l’autre, et les filtres vides sont désactivés.
  • Le statut ne repose jamais sur la couleur seule : chaque état a sa propre marque, les lecteurs d’écran entendent « Passed » ou « Failed » avant chaque test, Expected et Received sont étiquetés, et les mots modifiés sont soulignés en couleurs forcées. Avec le mouvement réduit, les lignes et les marques apparaissent sans glisser.

Construit avec

Les composants HextaUI gratuits dont Test Results est constitué. Chacun s’installe séparément.

Code

7 fichiers, ajoutés à components/blocks/test-results.