Meet Frontfriend.Your brand. Your rules. Your agents, connected.Explore what’s new

Test Results

A test run grouped by suite, with passed, failed, and skipped tests and failure details.

Interactive preview

The shared preview uses React Base. The source below uses Reka UI. Each example runs with local data.

Installation

Complete project setup with vue/reka, then install these components:

pnpm exec frontfriend download trace code-block icon button message bubble -f vue --primitive reka

Count results from the tests, not from the tool status. Open failed suites and failed tests by default. A skipped test is distinct from a test that is waiting to run. The example uses fixed test results and does not execute tests.

Usage

Complete example

Save as TestResultsExample.vue.

<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from "vue";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
type Phase = "complete" | "running" | "error" | "pending";
const props = withDefaults(defineProps<{ state?: Phase }>(), {
  state: "complete",
});
const phase = ref<Phase>(props.state);
let timer: ReturnType<typeof setTimeout> | undefined;
watch(
  () => props.state,
  (value) => {
    clearTimeout(timer);
    phase.value = value;
  },
);
onBeforeUnmount(() => clearTimeout(timer));
function replay() {
  clearTimeout(timer);
  expanded.value = {};
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
    phase.value = "complete";
    return;
  }
  phase.value = "running";
  timer = setTimeout(() => {
    phase.value = "complete";
  }, 3600);
}
const expanded = ref<Record<string, boolean>>({});
function expand(id: string, value: boolean) {
  expanded.value = { ...expanded.value, [id]: value };
}
interface TestResult {
  name: string;
  status: "passed" | "failed" | "skipped";
  error?: string;
}
const suites: { name: string; tests: TestResult[] }[] = [
  {
    name: "cart.test.ts",
    tests: [
      { name: "adds an item", status: "passed" },
      { name: "removes an item", status: "passed" },
      { name: "applies a discount code", status: "passed" },
    ],
  },
  {
    name: "payment.test.ts",
    tests: [
      { name: "charges the saved card", status: "passed" },
      {
        name: "retries a declined card",
        status: "failed",
        error:
          "Expected 2 charge attempts, received 1\n\n  at src/payment.test.ts:58:22",
      },
      { name: "refunds a payment", status: "skipped" },
    ],
  },
];
function counts(tests: TestResult[]) {
  const failed = tests.filter((test) => test.status === "failed").length;
  const skipped = tests.filter((test) => test.status === "skipped").length;
  return `${tests.length} tests${failed ? `, ${failed} failed` : ""}${skipped ? `, ${skipped} skipped` : ""}`;
}
</script>

<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      min-height: 288px;
      margin-inline: auto;
      display: flex;
      flex-direction: column;
      gap: 16px;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <Message align="end">
      <MessageContent>
        <Bubble align="end" variant="muted">
          <BubbleContent>Run the checkout tests.</BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
    <Message>
      <MessageContent style="gap: 16px; min-width: 0">
        <Trace>
          <TraceCollapsible
            :unmount-on-hide="false"
            v-if="phase === 'complete'"
            :open="
              expanded.run ??
              suites.some((suite) =>
                suite.tests.some((test) => test.status === 'failed'),
              )
            "
            @update:open="expand('run', $event)"
          >
            <TraceCollapsibleTrigger>
              <TraceLine
                :status="
                  suites.some((suite) =>
                    suite.tests.some((test) => test.status === 'failed'),
                  )
                    ? 'error'
                    : undefined
                "
              >
                <TraceIcon>
                  <Icon name="FlaskConical" />
                </TraceIcon>
                <TraceTitle>
                  Ran {{ counts(suites.flatMap((suite) => suite.tests)) }}
                </TraceTitle>
              </TraceLine>
            </TraceCollapsibleTrigger>
            <TraceCollapsibleContent>
              <Trace>
                <TraceCollapsible
                  :unmount-on-hide="false"
                  v-for="suite in suites"
                  :key="suite.name"
                  :open="
                    expanded[suite.name] ??
                    suite.tests.some((test) => test.status === 'failed')
                  "
                  @update:open="expand(suite.name, $event)"
                >
                  <TraceCollapsibleTrigger>
                    <TraceLine
                      :status="
                        suite.tests.some((test) => test.status === 'failed')
                          ? 'error'
                          : undefined
                      "
                    >
                      <TraceIcon>
                        <Icon name="FileCode" />
                      </TraceIcon>
                      <TraceTitle>
                        {{ suite.name }}, {{ counts(suite.tests) }}
                      </TraceTitle>
                    </TraceLine>
                  </TraceCollapsibleTrigger>
                  <TraceCollapsibleContent>
                    <Trace>
                      <template v-for="test in suite.tests" :key="test.name">
                        <TraceCollapsible
                          :unmount-on-hide="false"
                          v-if="test.status === 'failed'"
                          :open="expanded[test.name] ?? true"
                          @update:open="expand(test.name, $event)"
                        >
                          <TraceCollapsibleTrigger>
                            <TraceLine status="error">
                              <TraceIcon>
                                <Icon name="X" />
                              </TraceIcon>
                              <TraceTitle>{{ test.name }}</TraceTitle>
                            </TraceLine>
                          </TraceCollapsibleTrigger>
                          <TraceCollapsibleContent>
                            <CodeBlock size="sm">
                              <CodeBlockContent wrap>
                                {{ test.error }}
                              </CodeBlockContent>
                            </CodeBlock>
                          </TraceCollapsibleContent>
                        </TraceCollapsible>
                        <TraceLine v-else>
                          <TraceIcon>
                            <Icon
                              :name="
                                test.status === 'skipped'
                                  ? 'CircleDashed'
                                  : 'Check'
                              "
                            />
                          </TraceIcon>
                          <TraceTitle>
                            {{ test.status === "skipped" ? "Skipped " : ""
                            }}{{ test.name }}
                          </TraceTitle>
                        </TraceLine>
                      </template>
                    </Trace>
                  </TraceCollapsibleContent>
                </TraceCollapsible>
              </Trace>
            </TraceCollapsibleContent>
          </TraceCollapsible>
          <TraceLine v-else :status="phase">
            <TraceIcon>
              <Icon name="FlaskConical" />
            </TraceIcon>
            <TraceTitle>
              {{
                phase === "running"
                  ? "Running"
                  : phase === "pending"
                    ? "Waiting to run"
                    : "Failed to run"
              }}
              pnpm test checkout
            </TraceTitle>
          </TraceLine>
        </Trace>
        <p v-if="phase === 'complete'">
          One test failed. The payment client does not retry a declined card, so
          the second charge attempt never happens.
        </p>
      </MessageContent>
    </Message>
    <div>
      <Button
        size="sm"
        variant="outline"
        :disabled="phase === 'running'"
        @click="replay"
      >
        Replay
      </Button>
    </div>
  </div>
</template>

AI SDK

The runTests tool returns suites with stable suite and test IDs, names, passed/failed/skipped status, and optional error text. The adapter derives counts from part.output.suites and opens failed suites. Tool execution errors use part.errorText; failed assertions use each test error.

The basic example runs without a provider. For the AI SDK adapter example only, install the SDK types:

pnpm add ai

Pass a message from your chat client to the adapter. The supplied message does not call a provider.

Save as AiTestResultsAdapter.vue.

<script setup lang="ts">
import type { UIMessage } from "ai";
import { computed, ref } from "vue";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceCollapsibleContent,
  TraceLine,
  TraceTitle,
  TraceIcon,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Icon } from "@/components/ui/icon";
type SdkTest = {
  id: string;
  name: string;
  status: "passed" | "failed" | "skipped";
  error?: string;
};
type SdkSuite = { id: string; name: string; tests: SdkTest[] };
type SdkTestMessage = UIMessage<
  unknown,
  {},
  {
    runTests: {
      input: { command: string };
      output: { suites: SdkSuite[] };
    };
  }
>;
type TestPart = Extract<
  SdkTestMessage["parts"][number],
  { type: "tool-runTests" }
>;

const props = defineProps<{ message: SdkTestMessage; streaming: boolean }>();
const parts = computed(() =>
  props.message.parts.filter(
    (part): part is TestPart => part.type === "tool-runTests",
  ),
);
function counts(suites: SdkSuite[]) {
  const tests = suites.flatMap((suite) => suite.tests);
  return {
    total: tests.length,
    failed: tests.filter((test) => test.status === "failed").length,
    skipped: tests.filter((test) => test.status === "skipped").length,
  };
}
function summary(suites: SdkSuite[]) {
  const { total, failed, skipped } = counts(suites);
  return `${total} tests${failed ? `, ${failed} failed` : ""}${skipped ? `, ${skipped} skipped` : ""}`;
}
function state(part: TestPart) {
  const pending = part.state === "approval-requested";
  const denied =
    part.state === "output-denied" ||
    (part.state === "approval-responded" && !part.approval.approved);
  const failed =
    part.state === "output-error" || (!pending && !denied && !props.streaming);
  return {
    title: `${denied ? "Denied" : pending ? "Waiting to run" : failed ? "Failed to run" : "Running"} ${part.input?.command ?? "tests"}`,
    status: pending
      ? "pending"
      : failed
        ? "error"
        : denied
          ? undefined
          : "running",
  } as const;
}
const open = ref<Record<string, boolean>>({});
function setOpen(id: string, value: boolean) {
  open.value = { ...open.value, [id]: value };
}
</script>
<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      margin-inline: auto;
      display: grid;
      gap: 16px;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <Trace>
      <template v-for="part in parts" :key="part.toolCallId">
        <TraceCollapsible
          :unmount-on-hide="false"
          v-if="part.state === 'output-available' && !part.preliminary"
          :open="open[part.toolCallId] ?? counts(part.output.suites).failed > 0"
          @update:open="setOpen(part.toolCallId, $event)"
        >
          <TraceCollapsibleTrigger>
            <TraceLine
              :status="counts(part.output.suites).failed ? 'error' : undefined"
            >
              <TraceIcon>
                <Icon name="FlaskConical" />
              </TraceIcon>
              <TraceTitle>Ran {{ summary(part.output.suites) }}</TraceTitle>
            </TraceLine>
          </TraceCollapsibleTrigger>
          <TraceCollapsibleContent>
            <Trace>
              <TraceCollapsible
                :unmount-on-hide="false"
                v-for="suite in part.output.suites"
                :key="suite.id"
                :open="
                  open[part.toolCallId + suite.id] ?? counts([suite]).failed > 0
                "
                @update:open="setOpen(part.toolCallId + suite.id, $event)"
              >
                <TraceCollapsibleTrigger>
                  <TraceLine
                    :status="counts([suite]).failed ? 'error' : undefined"
                  >
                    <TraceTitle>
                      {{ suite.name }}, {{ summary([suite]) }}
                    </TraceTitle>
                  </TraceLine>
                </TraceCollapsibleTrigger>
                <TraceCollapsibleContent>
                  <Trace>
                    <template v-for="test in suite.tests" :key="test.id">
                      <TraceCollapsible
                        :unmount-on-hide="false"
                        v-if="test.error"
                        :open="
                          open[part.toolCallId + suite.id + test.id] ?? true
                        "
                        @update:open="
                          setOpen(part.toolCallId + suite.id + test.id, $event)
                        "
                      >
                        <TraceCollapsibleTrigger>
                          <TraceLine
                            :status="
                              test.status === 'failed' ? 'error' : undefined
                            "
                          >
                            <TraceTitle>{{ test.name }}</TraceTitle>
                          </TraceLine>
                        </TraceCollapsibleTrigger>
                        <TraceCollapsibleContent>
                          <CodeBlock size="sm" variant="destructive">
                            <CodeBlockContent wrap>
                              {{ test.error }}
                            </CodeBlockContent>
                          </CodeBlock>
                        </TraceCollapsibleContent>
                      </TraceCollapsible>
                      <TraceLine
                        v-else
                        :status="test.status === 'failed' ? 'error' : undefined"
                      >
                        <TraceIcon>
                          <Icon
                            :name="
                              test.status === 'skipped'
                                ? 'CircleDashed'
                                : test.status === 'failed'
                                  ? 'X'
                                  : 'Check'
                            "
                          />
                        </TraceIcon>
                        <TraceTitle>
                          {{
                            test.status === "skipped"
                              ? `Skipped ${test.name}`
                              : test.name
                          }}
                        </TraceTitle>
                      </TraceLine>
                    </template>
                  </Trace>
                </TraceCollapsibleContent>
              </TraceCollapsible>
            </Trace>
          </TraceCollapsibleContent>
        </TraceCollapsible>
        <template v-else>
          <TraceLine :status="state(part).status">
            <TraceIcon>
              <Icon name="FlaskConical" />
            </TraceIcon>
            <TraceTitle>{{ state(part).title }}</TraceTitle>
          </TraceLine>
          <CodeBlock
            v-if="part.state === 'output-error'"
            size="sm"
            variant="destructive"
          >
            <CodeBlockContent wrap>{{ part.errorText }}</CodeBlockContent>
          </CodeBlock>
        </template>
      </template>
    </Trace>
  </div>
</template>

Suites

Save as TestResultsSuitesExample.vue.

<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace";

interface Test {
  name: string;
  status: "passed" | "failed" | "skipped";
  error?: string;
}

const suites: { name: string; tests: Test[] }[] = [
  {
    name: "auth.test.ts",
    tests: [
      { name: "logs in with valid credentials", status: "passed" },
      { name: "rejects an invalid password", status: "passed" },
      { name: "refreshes an expired token", status: "passed" },
    ],
  },
  {
    name: "user-api.test.ts",
    tests: [
      { name: "creates a user", status: "passed" },
      {
        name: "updates a user profile",
        status: "failed",
        error: `Expected status 200, received 500

  at Object.<anonymous> (src/user-api.test.ts:45:12)`,
      },
      { name: "deletes a user", status: "skipped" },
    ],
  },
  {
    name: "db.test.ts",
    tests: [
      { name: "connects to the database", status: "passed" },
      {
        name: "times out a slow query",
        status: "failed",
        error: `Exceeded timeout of 5000 ms for a test.

  at Object.<anonymous> (src/db.test.ts:22:3)`,
      },
    ],
  },
];

function countTests(tests: Test[]) {
  return {
    total: tests.length,
    failed: tests.filter((test) => test.status === "failed").length,
    skipped: tests.filter((test) => test.status === "skipped").length,
  };
}

function formatCounts({
  total,
  failed,
  skipped,
}: ReturnType<typeof countTests>) {
  return [
    `${total} tests`,
    failed > 0 && `${failed} failed`,
    skipped > 0 && `${skipped} skipped`,
  ]
    .filter(Boolean)
    .join(", ");
}

const summary = countTests(suites.flatMap((suite) => suite.tests));
</script>

<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      margin-inline: auto;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <Trace>
      <TraceCollapsible default-open :unmount-on-hide="false">
        <TraceCollapsibleTrigger>
          <TraceLine :status="summary.failed > 0 ? 'error' : undefined">
            <TraceIcon>
              <Icon name="FlaskConical" />
            </TraceIcon>
            <TraceTitle>Ran {{ formatCounts(summary) }}</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <Trace>
            <TraceCollapsible
              :unmount-on-hide="false"
              v-for="suite in suites"
              :key="suite.name"
              :default-open="countTests(suite.tests).failed > 0"
            >
              <TraceCollapsibleTrigger>
                <TraceLine
                  :status="
                    countTests(suite.tests).failed > 0 ? 'error' : undefined
                  "
                >
                  <TraceIcon>
                    <Icon name="FileCode" />
                  </TraceIcon>
                  <TraceTitle>
                    {{ suite.name }},
                    {{ formatCounts(countTests(suite.tests)) }}
                  </TraceTitle>
                </TraceLine>
              </TraceCollapsibleTrigger>
              <TraceCollapsibleContent>
                <Trace>
                  <template v-for="test in suite.tests" :key="test.name">
                    <TraceCollapsible
                      :unmount-on-hide="false"
                      v-if="test.status === 'failed'"
                    >
                      <TraceCollapsibleTrigger>
                        <TraceLine status="error">
                          <TraceIcon>
                            <Icon name="X" />
                          </TraceIcon>
                          <TraceTitle>{{ test.name }}</TraceTitle>
                        </TraceLine>
                      </TraceCollapsibleTrigger>
                      <TraceCollapsibleContent>
                        <CodeBlock size="sm">
                          <CodeBlockContent wrap>
                            {{ test.error }}
                          </CodeBlockContent>
                        </CodeBlock>
                      </TraceCollapsibleContent>
                    </TraceCollapsible>
                    <TraceLine v-else>
                      <TraceIcon>
                        <Icon
                          name="CircleDashed"
                          v-if="test.status === 'skipped'"
                        />
                        <Icon name="Check" v-else />
                      </TraceIcon>
                      <TraceTitle>
                        {{
                          test.status === "skipped"
                            ? `Skipped ${test.name}`
                            : test.name
                        }}
                      </TraceTitle>
                    </TraceLine>
                  </template>
                </Trace>
              </TraceCollapsibleContent>
            </TraceCollapsible>
          </Trace>
        </TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
  </div>
</template>

Components and API

This composition has no separate component API. Use the APIs for its installed components.

ComponentTarget reference
trace Reka UI API
code-block Reka UI API
icon Reka UI API
button Reka UI API
message Reka UI API
bubble Reka UI API