Test Results

Zeig den Testlauf eines Agents, während er läuft. Tests strömen in ihre Dateien und klappen dann zu „118 passed, 2 failed in 4.2s“ ein, wobei jeder Fehler offen bleibt. Expected- und Received-Diffs auf Wortebene, Timeouts und Snapshots, Stacks aus Stack Trace, Filter, Fehlgeschlagene erneut ausführen und Beheben lassen, dazu Adapter für Vitest, Jest, Playwright und pytest.

Wenn ein Agent deine Tests ausführt, sollte der Chat zeigen, worauf ein Entwickler achtet: wie weit er ist, was fehlgeschlagen ist und warum. Während Tests laufen, zeigt Test Results eine Live-Zeile „Running tests · 42 of 120“ mit Zahlen, die Ziffer für Ziffer weiterticken, und einem Balken, der in bestanden, fehlgeschlagen und übersprungen aufgeteilt ist. Tests strömen in ihre Dateien, sobald sie fertig sind, und die laufende Datei bleibt geöffnet.

Wenn der Lauf endet, wird die Zeile zu einer Zusammenfassung in der Vergangenheit wie „118 passed, 2 failed in 4.2s“. Ein sauberer Lauf klappt ein. Ein fehlgeschlagener bleibt offen, mit eingeklappten bestandenen und geöffneten fehlgeschlagenen Dateien, außer du hast sie selbst geöffnet oder geschlossen. Jeder Fehler zeigt seine Meldung, einen Diff von Expected und Received auf Wortebene und seinen Stack über Stack Trace, mit deinen Frames zuerst und eingeklappten Framework-Frames. Timeouts und Snapshot-Abweichungen bekommen eigene Formulierungen.

Filtere nach All, Failed, Skipped oder Slow, kopiere einen Fehler als Text, öffne Frames in deinem Editor und schicke Ask to fix, Rerun failed, Rerun und Update snapshot an den Agenten zurück. fromVitestJson, fromJestJson, fromPlaywrightJson und fromJUnitXml machen aus Reports einen Lauf, und applyTestEvent fügt Live-Events zu einem zusammen.

  1. HextaUI Pro holen

    Eine einmalige Zahlung schaltet jeden Block frei, auch künftige. Preise ansehen.

  2. Die Pro-Registry zu components.json hinzufügen

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Token hinzufügen

    Erstelle auf deiner Kontoseite einen Token und trage ihn in .env.local als HEXTAUI_PRO_TOKEN ein.

  4. Den Block hinzufügen

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

Mit dem AI SDK

Mach runTests zu einem Tool, dessen execute den Lauf nach jedem Reporter-Event liefert, sodass jedes Update als vorläufige Ausgabe ankommt. Rendere ihn in einem ToolCall, der offen bleibt, wenn Tests fehlschlagen, und schicke Ask to fix und Rerun failed als Nachrichten an den Agenten zurück.

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

Aus einem gespeicherten Report

fromVitestJson und fromJestJson lesen die Ausgabe des JSON-Reporters. Übergib eine root, um absolute Pfade zu kürzen, und getEditorHref, um Frames in Cursor oder VS Code zu öffnen.

"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 und JUnit-XML

fromJUnitXml liest pytest --junitxml und andere JUnit-Reports. Testklassen werden zu describe-Blöcken, der fehlschlagende Quelltext erscheint als Code, und Rerun failed liefert dir die 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("::")
          )
        )
      }
    />
  )
}

Aus deinen eigenen Events

Sende run-start-, test-start-, test-end-, file-end- und run-end-Events von einem beliebigen Runner und füge sie mit applyTestEvent zusammen. Die Funktion ist rein und funktioniert daher in einem Reducer, einem Store oder auf dem 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} />
}

Aufbau

Die Teile, die du zusammensetzt, von außen nach innen.

PartBeschreibung
TestResultsDer Lauf: Live-Kopf, Fortschritt, Filter, Aktionen und Dateien.
ValueDiffExpected und Received, mit markierten geänderten Wörtern.
applyTestEvent, createTestRunReine Helfer, die aus gestreamten Events einen Lauf bauen.
fromVitestJson, fromJestJson, fromPlaywrightJson, fromJUnitXmlAdapter, die Reporter-Ausgaben in einen Lauf umwandeln.
summarizeRun, formatFailuresDie Zusammenfassungszeile und Fehler als einfacher Text für einen Prompt oder die Zwischenablage.

TestResults

Akzeptiert außerdem alle section-Props außer children.

PropTypStandard
runDer Lauf, aus einem Adapter, applyTestEvent oder deinen eigenen Daten.
TestRun–
titleDas Live-Label, während Tests laufen.
string"Running tests"
labelZugänglicher Name der Region.
string"Test results"
slowThresholdTests, die mindestens so viele Millisekunden dauern, gelten als langsam.
number300
rootProjektwurzel, die aus Pfaden in Stacks entfernt wird.
string–
defaultOpenÜberschreibt, ob der Lauf geöffnet ist. Standardmäßig bleibt er offen, solange er läuft und wenn etwas fehlgeschlagen ist.
boolean–
defaultFilterDer Filter, mit dem er startet.
"all" | "failed" | "skipped" | "slow""all"
onAskToFixZeigt Ask to fix am Lauf und an jedem Fehler.
(failed: FailedTest[]) => void–
onRerunFailedZeigt Rerun failed, sobald der Lauf mit Fehlern endet.
(failed: FailedTest[]) => void–
onRerunTestZeigt Rerun an jedem Fehler.
(failed: FailedTest) => void–
onUpdateSnapshotZeigt Update snapshot bei Snapshot-Abweichungen.
(failed: FailedTest) => void–
getFrameHrefMacht Stack-Frames zu Links, zum Beispiel mit getEditorHref.
(frame: StackFrame) => string | undefined–
onFrameClickWird aufgerufen, wenn ein Frame angeklickt wird.
(frame: StackFrame, event: MouseEvent) => void–
PropTypStandard
statusOb noch Tests laufen.
"running" | "done" | "cancelled"–
filesDateien in der Reihenfolge, in der sie gestartet sind, jeweils mit Tests, Dauer und einem Fehler, falls sie nicht geladen werden konnten.
TestFile[]–
totalWie viele Tests zu erwarten sind, für „42 of 120“. Standardmäßig die pro Datei gesammelten Zahlen.
number–
durationMillisekunden, für die Zusammenfassung. Wird während des Laufs gemessen, wenn nicht angegeben.
number–
startedAtWann der Lauf gestartet ist, als Zeitstempel, für den Live-Timer.
number–
PropTypStandard
nameDer eigene Name des Tests.
string–
suiteUmschließende describe-Blöcke, der äußerste zuerst.
string[]–
statusDer Zustand des Tests.
"queued" | "running" | "passed" | "failed" | "skipped" | "todo"–
durationMillisekunden.
number–
retriesWie oft er wiederholt wurde.
number–
failureWarum er fehlgeschlagen ist.
TestFailure–
PropTypStandard
kindBestimmt die Formulierung, etwa „Timed out after 5s“ oder „Snapshot mismatch“.
"assertion" | "snapshot" | "timeout" | "error"–
messageDie Assertion-Meldung, ohne die Zeilen Expected und Received.
string–
expected, receivedBeide Seiten des Vergleichs, gelesen aus dem Fehler, einem Diff oder der Meldung.
string–
stackText für parseStackTrace. Fehlt, wenn es keine Frames gibt.
string–
detailsZusätzliche Ausgabe, etwa der fehlschlagende Quelltext von pytest.
string–
rawDer Originaltext, den Copy verwendet.
string–

applyTestEvent(run, event)

Gibt einen neuen Lauf zurück. createTestRun() liefert einen leeren zum Start.

PropTypStandard
eventEines von run-start, file-start, test-start, test-end, file-end und run-end. Tests werden über die id zugeordnet, oder über Datei, Suite und Namen.
TestEvent–
event.errorEin String oder ein Objekt mit message, stack, expected, actual und diff, wie Vitests TestError.
TestErrorInput–

fromVitestJson(report, options)

fromJestJson nimmt denselben Report. fromPlaywrightJson liest Playwrights JSON-Reporter, und fromJUnitXml(xml, options) liest JUnit-XML.

PropTypStandard
options.rootProjektwurzel, die aus Dateipfaden entfernt wird. Ohne sie beginnen Pfade beim ersten Ordner wie src oder tests.
string–
TasteAktion
TabSpringt durch den Lauf-Kopf, den Filter, die Aktionen, jede Datei und jeden fehlgeschlagenen Test.
EnterSpaceÖffnet oder schließt den Lauf, eine Datei oder einen Fehler.
←→Wechselt zwischen den Filtern.
  • Der Lauf ist eine beschriftete Region mit aria-busy, solange Tests laufen. Eine höfliche Live-Region sagt die abschließende Zusammenfassung einmal an, sodass gestreamte Tests und tickende Zahlen still bleiben.
  • Der Lauf-Kopf, jede Datei und jeder fehlgeschlagene Test sind Disclosure-Buttons mit aria-expanded. Ihre Namen lesen die Zusammenfassung, die Zahlen der Datei oder Status und Namen des Tests vor.
  • Der Filter ist eine Toggle-Gruppe. Pfeiltasten wechseln zwischen den Optionen, und Filter ohne Inhalt sind deaktiviert.
  • Status ist nie nur Farbe: Jeder Zustand hat sein eigenes Zeichen, Screenreader hören „Passed“ oder „Failed“ vor jedem Test, Expected und Received sind beschriftet, und geänderte Wörter werden in erzwungenen Farben unterstrichen. Bei reduzierter Bewegung erscheinen Zeilen und Zeichen ohne Gleiten.

Gebaut mit

Die kostenlosen HextaUI-Komponenten, aus denen Test Results besteht. Jede lässt sich einzeln installieren.

Code

7 Dateien, hinzugefügt zu components/blocks/test-results.