Test Results

エージェントのテスト実行を進行中のまま表示します。テストはそれぞれのファイルに流れ込み、終わると「118 passed, 2 failed in 4.2s」に折りたたまれ、失敗はすべて開いたまま残ります。単語単位の Expected と Received の差分、タイムアウトとスナップショット、Stack Trace によるスタック、フィルター、失敗したテストの再実行と修正の依頼、さらに Vitest、Jest、Playwright、pytest 用のアダプターを備えます。

エージェントがテストを実行するとき、チャットには開発者が知りたいことを表示するべきです。どこまで進んだか、何が失敗したか、そしてなぜか。実行中、Test Results は「Running tests · 42 of 120」というライブの行を表示し、件数は桁ごとに進み、バーは成功・失敗・スキップに分かれます。テストは終わるたびにファイルへ流れ込み、実行中のファイルは開いたままです。

実行が終わると、行は「118 passed, 2 failed in 4.2s」のような過去形のサマリーに変わります。問題のない実行は折りたたまれます。失敗した実行は開いたままで、成功したファイルは折りたたまれ、失敗したファイルは開いたままです。ただし自分で開閉した場合はそれに従います。各失敗にはメッセージ、単語単位の Expected と Received の diff、そして Stack Trace によるスタックが表示され、自分のフレームが先に、フレームワークのフレームは折りたたまれます。タイムアウトとスナップショットの不一致には専用の表現があります。

All、Failed、Skipped、Slow で絞り込み、失敗をテキストでコピーし、フレームをエディターで開き、Ask to fix、Rerun failed、Rerun、Update snapshot をエージェントに返せます。fromVitestJson、fromJestJson、fromPlaywrightJson、fromJUnitXml はレポートを実行に変換し、applyTestEvent はライブイベントを一つの実行にまとめます。

  1. HextaUI Pro を入手する

    一度の支払いで、今後追加されるものも含めてすべてのブロックが使えます。料金を見る。

  2. Proレジストリをcomponents.jsonに追加する

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. トークンを追加する

    アカウントページでトークンを作成し、.env.local に HEXTAUI_PRO_TOKEN として設定してください。

  4. ブロックを追加する

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

AI SDKと使う

runTests を、レポーターのイベントごとに実行を返す execute を持つツールにすると、更新がそれぞれ暫定出力として届きます。テストが失敗したら開いたままになる ToolCall の中に表示し、Ask to fix と Rerun failed をメッセージとしてエージェントに返します。

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

保存したレポートから

fromVitestJson と fromJestJson は JSON レポーターの出力を読み込みます。root を渡すと絶対パスを短くでき、getEditorHref を渡すとフレームを Cursor や 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 と JUnit XML

fromJUnitXml は pytest --junitxml などの JUnit レポートを読み込みます。テストクラスは describe ブロックになり、失敗したソースはコードとして表示され、Rerun failed にはノード ID が渡されます。

"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("::")
          )
        )
      }
    />
  )
}

独自のイベントから

任意のランナーから run-start、test-start、test-end、file-end、run-end のイベントを送り、applyTestEvent でまとめます。純粋関数なので、reducer、ストア、サーバーのどこでも動きます。

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

構造

外側から内側へ組み合わせるパーツ。

パーツ説明
TestResults実行:ライブのヘッダー、進捗、フィルター、アクション、ファイル。
ValueDiff変わった単語に印を付けた Expected と Received。
applyTestEvent, createTestRunストリーミングされたイベントから実行を組み立てる純粋なヘルパー。
fromVitestJson, fromJestJson, fromPlaywrightJson, fromJUnitXmlレポーターの出力を実行に変換するアダプター。
summarizeRun, formatFailuresサマリーの一行と、プロンプトやクリップボード向けのプレーンテキストの失敗。

TestResults

children 以外のすべての section の props も受け付けます。

プロパティ型デフォルト
runアダプター、applyTestEvent、または独自データからの実行。
TestRun–
titleテスト実行中のライブラベル。
string"Running tests"
labelリージョンのアクセシブルな名前。
string"Test results"
slowThresholdこのミリ秒以上かかったテストを遅いテストとして扱います。
number300
rootスタック内のパスから取り除くプロジェクトのルート。
string–
defaultOpen実行を開くかどうかを上書きします。既定では実行中と何かが失敗したときに開いたままになります。
boolean–
defaultFilter最初に選ばれているフィルター。
"all" | "failed" | "skipped" | "slow""all"
onAskToFix実行と各失敗に Ask to fix を表示します。
(failed: FailedTest[]) => void–
onRerunFailed実行が失敗ありで終わったら Rerun failed を表示します。
(failed: FailedTest[]) => void–
onRerunTest各失敗に Rerun を表示します。
(failed: FailedTest) => void–
onUpdateSnapshotスナップショットの不一致に Update snapshot を表示します。
(failed: FailedTest) => void–
getFrameHrefスタックフレームをリンクに変えます。例えば getEditorHref を使います。
(frame: StackFrame) => string | undefined–
onFrameClickフレームがクリックされたときに呼ばれます。
(frame: StackFrame, event: MouseEvent) => void–
プロパティ型デフォルト
statusテストがまだ実行中かどうか。
"running" | "done" | "cancelled"–
files開始した順のファイル。それぞれにテスト、所要時間、読み込めなかった場合はエラーが含まれます。
TestFile[]–
total「42 of 120」のための予定テスト数。省略時はファイルごとに集めた件数です。
number–
durationサマリー用のミリ秒。省略すると実行中に計測します。
number–
startedAtライブタイマー用の、実行が始まった時刻のタイムスタンプ。
number–
プロパティ型デフォルト
nameテスト自身の名前。
string–
suite外側から順に並んだ、囲んでいる describe ブロック。
string[]–
statusテストの状態。
"queued" | "running" | "passed" | "failed" | "skipped" | "todo"–
durationミリ秒。
number–
retries再試行された回数。
number–
failure失敗した理由。
TestFailure–
プロパティ型デフォルト
kind「Timed out after 5s」や「Snapshot mismatch」などの表現を選びます。
"assertion" | "snapshot" | "timeout" | "error"–
messageExpected と Received の行を除いたアサーションのメッセージ。
string–
expected, received比較の両側。エラー、diff、またはメッセージから読み取ります。
string–
stackparseStackTrace に渡すテキスト。フレームがないときは省略されます。
string–
detailspytest の失敗したソースなどの追加出力。
string–
rawCopy が使う元のテキスト。
string–

applyTestEvent(run, event)

新しい実行を返します。createTestRun() は最初に使う空の実行を返します。

プロパティ型デフォルト
eventrun-start、file-start、test-start、test-end、file-end、run-end のいずれか。テストは id、またはファイル・スイート・名前で照合されます。
TestEvent–
event.error文字列、または Vitest の TestError のように message、stack、expected、actual、diff を持つオブジェクト。
TestErrorInput–

fromVitestJson(report, options)

fromJestJson も同じレポートを受け取ります。fromPlaywrightJson は Playwright の JSON レポーターを、fromJUnitXml(xml, options) は JUnit XML を読み込みます。

プロパティ型デフォルト
options.rootファイルパスから取り除くプロジェクトのルート。省略すると、パスは src や tests などの最初のフォルダーから始まります。
string–
キーアクション
Tab実行のヘッダー、フィルター、アクション、各ファイル、各失敗テストを順に移動します。
EnterSpace実行、ファイル、または失敗を開閉します。
←→フィルター間を移動します。
  • 実行はテスト中に aria-busy を持つ名前付きリージョンです。丁寧なライブリージョンが最終サマリーを一度だけ読み上げるので、流れ込むテストや増えていく件数は静かなままです。
  • 実行のヘッダー、各ファイル、各失敗テストは aria-expanded を持つ開閉ボタンです。名前はサマリー、ファイルの件数、またはテストの状態と名前を読み上げます。
  • フィルターはトグルグループです。矢印キーで選択肢を移動でき、該当がないフィルターは無効になります。
  • 状態を色だけで示すことはありません。状態ごとに専用のマークがあり、スクリーンリーダーは各テストの前に「Passed」または「Failed」を読み上げ、Expected と Received にはラベルがあり、変わった単語は強制カラーで下線が付きます。視差効果を減らす設定では、行とマークはスライドせずに表示されます。

使用技術

Test Results を構成する無料のHextaUIコンポーネントです。それぞれ単独でインストールできます。

コード

7 個のファイルを components/blocks/test-results に追加しました。