|
|
@@ -1,5 +1,7 @@
|
|
|
import type { ReactNode } from "react";
|
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
+import { createRoot } from "react-dom/client";
|
|
|
+import { act } from "react";
|
|
|
import { NextIntlClientProvider } from "next-intl";
|
|
|
import { Window } from "happy-dom";
|
|
|
import { describe, expect, test, vi } from "vitest";
|
|
|
@@ -55,6 +57,10 @@ vi.mock("@/components/ui/dialog", () => {
|
|
|
};
|
|
|
});
|
|
|
|
|
|
+vi.mock("@/lib/utils/provider-chain-formatter", () => ({
|
|
|
+ formatProviderTimeline: () => ({ timeline: "timeline", totalDuration: 123 }),
|
|
|
+}));
|
|
|
+
|
|
|
import { ErrorDetailsDialog } from "./error-details-dialog";
|
|
|
|
|
|
const messages = {
|
|
|
@@ -70,6 +76,28 @@ const messages = {
|
|
|
processing: "Processing",
|
|
|
success: "Success",
|
|
|
error: "Error",
|
|
|
+ skipped: {
|
|
|
+ title: "Skipped",
|
|
|
+ reason: "Reason",
|
|
|
+ warmup: "Warmup",
|
|
|
+ desc: "Warmup skipped",
|
|
|
+ },
|
|
|
+ blocked: {
|
|
|
+ title: "Blocked",
|
|
|
+ type: "Type",
|
|
|
+ sensitiveWord: "Sensitive word",
|
|
|
+ word: "Word",
|
|
|
+ matchType: "Match type",
|
|
|
+ matchTypeContains: "Contains",
|
|
|
+ matchTypeExact: "Exact",
|
|
|
+ matchTypeRegex: "Regex",
|
|
|
+ matchedText: "Matched text",
|
|
|
+ },
|
|
|
+ modelRedirect: {
|
|
|
+ title: "Model redirect",
|
|
|
+ billingOriginal: "Billing original",
|
|
|
+ billingRedirected: "Billing redirected",
|
|
|
+ },
|
|
|
specialSettings: {
|
|
|
title: "Special settings",
|
|
|
},
|
|
|
@@ -87,6 +115,16 @@ const messages = {
|
|
|
success: "No error (success)",
|
|
|
default: "No error",
|
|
|
},
|
|
|
+ errorMessage: "Error message",
|
|
|
+ filteredProviders: "Filtered providers",
|
|
|
+ providerChain: {
|
|
|
+ title: "Provider chain",
|
|
|
+ totalDuration: "Total duration: {duration}",
|
|
|
+ },
|
|
|
+ reasons: {
|
|
|
+ rateLimited: "Rate limited",
|
|
|
+ circuitOpen: "Circuit open",
|
|
|
+ },
|
|
|
},
|
|
|
billingDetails: {
|
|
|
input: "Input",
|
|
|
@@ -280,4 +318,341 @@ describe("error-details-dialog layout", () => {
|
|
|
"md:grid-cols-2"
|
|
|
);
|
|
|
});
|
|
|
+
|
|
|
+ test("uses gray status class for unexpected statusCode (e.g., 100)", () => {
|
|
|
+ const html = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={100}
|
|
|
+ errorMessage={null}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ />
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(html).toContain("bg-gray-100");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("covers 3xx and 4xx status badge classes", () => {
|
|
|
+ const html3xx = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={302}
|
|
|
+ errorMessage={null}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ expect(html3xx).toContain("bg-blue-100");
|
|
|
+
|
|
|
+ const html4xx = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={404}
|
|
|
+ errorMessage={null}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ expect(html4xx).toContain("bg-yellow-100");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("covers in-progress state when statusCode is null", () => {
|
|
|
+ const html = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={null}
|
|
|
+ errorMessage={null}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ />
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(html).toContain("In progress");
|
|
|
+ expect(html).toContain("Processing");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("renders filtered providers and provider chain timeline when present", () => {
|
|
|
+ const html = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={200}
|
|
|
+ errorMessage={null}
|
|
|
+ sessionId={null}
|
|
|
+ providerChain={
|
|
|
+ [
|
|
|
+ {
|
|
|
+ id: 1,
|
|
|
+ name: "p1",
|
|
|
+ reason: "request_success",
|
|
|
+ statusCode: 200,
|
|
|
+ decisionContext: {
|
|
|
+ filteredProviders: [
|
|
|
+ {
|
|
|
+ id: 2,
|
|
|
+ name: "filtered-provider",
|
|
|
+ reason: "rate_limited",
|
|
|
+ details: "$1",
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ] as any
|
|
|
+ }
|
|
|
+ />
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(html).toContain("Filtered providers");
|
|
|
+ expect(html).toContain("filtered-provider");
|
|
|
+ expect(html).toContain("Provider chain");
|
|
|
+ expect(html).toContain("timeline");
|
|
|
+ expect(html).toContain("Total duration");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("formats JSON rate limit error message and filtered providers", () => {
|
|
|
+ const html = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={429}
|
|
|
+ errorMessage={JSON.stringify({
|
|
|
+ code: "rate_limit_exceeded",
|
|
|
+ message: "Rate limited",
|
|
|
+ details: {
|
|
|
+ filteredProviders: [{ id: 1, name: "p", reason: "rate_limited", details: "$1" }],
|
|
|
+ },
|
|
|
+ })}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ />
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(html).toContain("Error message");
|
|
|
+ expect(html).toContain("Rate limited");
|
|
|
+ expect(html).toContain("p");
|
|
|
+ expect(html).toContain("$1");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("formats non-rate-limit JSON error as pretty JSON", () => {
|
|
|
+ const html = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={500}
|
|
|
+ errorMessage={JSON.stringify({ error: "E", code: "other" })}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ />
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(html).toContain("Error message");
|
|
|
+ expect(html).toContain(""error"");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("falls back to raw error message when it is not JSON", () => {
|
|
|
+ const html = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={500}
|
|
|
+ errorMessage={"not-json"}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ />
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(html).toContain("Error message");
|
|
|
+ expect(html).toContain("not-json");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("renders warmup skipped and blocked sections when applicable", () => {
|
|
|
+ const html = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={200}
|
|
|
+ errorMessage={null}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ blockedBy={"warmup"}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ expect(html).toContain("Skipped");
|
|
|
+ expect(html).toContain("Warmup");
|
|
|
+
|
|
|
+ const htmlBlocked = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={200}
|
|
|
+ errorMessage={null}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ blockedBy={"sensitive_word"}
|
|
|
+ blockedReason={JSON.stringify({ word: "bad", matchType: "contains", matchedText: "bad" })}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ expect(htmlBlocked).toContain("Blocked");
|
|
|
+ expect(htmlBlocked).toContain("Sensitive word");
|
|
|
+ expect(htmlBlocked).toContain("bad");
|
|
|
+ expect(htmlBlocked).toContain("Contains");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("renders model redirect section when originalModel != currentModel", () => {
|
|
|
+ const html = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={200}
|
|
|
+ errorMessage={null}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ originalModel={"m1"}
|
|
|
+ currentModel={"m2"}
|
|
|
+ billingModelSource={"original"}
|
|
|
+ />
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(html).toContain("Model redirect");
|
|
|
+ expect(html).toContain("m1");
|
|
|
+ expect(html).toContain("m2");
|
|
|
+ expect(html).toContain("Billing original");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("scrolls to model redirect section when scrollToRedirect is true", async () => {
|
|
|
+ vi.useFakeTimers();
|
|
|
+ const container = document.createElement("div");
|
|
|
+ document.body.appendChild(container);
|
|
|
+
|
|
|
+ const scrollIntoViewMock = vi.fn();
|
|
|
+ const originalScrollIntoView = Element.prototype.scrollIntoView;
|
|
|
+ Object.defineProperty(Element.prototype, "scrollIntoView", {
|
|
|
+ value: scrollIntoViewMock,
|
|
|
+ configurable: true,
|
|
|
+ });
|
|
|
+
|
|
|
+ const root = createRoot(container);
|
|
|
+ await act(async () => {
|
|
|
+ root.render(
|
|
|
+ <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={200}
|
|
|
+ errorMessage={null}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ scrollToRedirect
|
|
|
+ originalModel={"m1"}
|
|
|
+ currentModel={"m2"}
|
|
|
+ />
|
|
|
+ </NextIntlClientProvider>
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ await act(async () => {
|
|
|
+ vi.advanceTimersByTime(150);
|
|
|
+ });
|
|
|
+
|
|
|
+ expect(scrollIntoViewMock).toHaveBeenCalled();
|
|
|
+
|
|
|
+ await act(async () => {
|
|
|
+ root.unmount();
|
|
|
+ });
|
|
|
+
|
|
|
+ Object.defineProperty(Element.prototype, "scrollIntoView", {
|
|
|
+ value: originalScrollIntoView,
|
|
|
+ configurable: true,
|
|
|
+ });
|
|
|
+ vi.useRealTimers();
|
|
|
+ container.remove();
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe("error-details-dialog multiplier", () => {
|
|
|
+ test("does not render multiplier row when costMultiplier is empty string", () => {
|
|
|
+ const html = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={500}
|
|
|
+ errorMessage={null}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ costUsd={"0.000001"}
|
|
|
+ costMultiplier={""}
|
|
|
+ inputTokens={100}
|
|
|
+ outputTokens={80}
|
|
|
+ />
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(html).not.toContain("Multiplier");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("does not render multiplier row when costMultiplier is undefined", () => {
|
|
|
+ const html = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={500}
|
|
|
+ errorMessage={null}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ costUsd={"0.000001"}
|
|
|
+ costMultiplier={undefined}
|
|
|
+ inputTokens={100}
|
|
|
+ outputTokens={80}
|
|
|
+ />
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(html).not.toContain("Multiplier");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("does not render multiplier row when costMultiplier is NaN", () => {
|
|
|
+ const html = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={500}
|
|
|
+ errorMessage={null}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ costUsd={"0.000001"}
|
|
|
+ costMultiplier={"NaN"}
|
|
|
+ inputTokens={100}
|
|
|
+ outputTokens={80}
|
|
|
+ />
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(html).not.toContain("Multiplier");
|
|
|
+ expect(html).not.toContain("NaN");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("does not render multiplier row when costMultiplier is Infinity", () => {
|
|
|
+ const html = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={500}
|
|
|
+ errorMessage={null}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ costUsd={"0.000001"}
|
|
|
+ costMultiplier={"Infinity"}
|
|
|
+ inputTokens={100}
|
|
|
+ outputTokens={80}
|
|
|
+ />
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(html).not.toContain("Multiplier");
|
|
|
+ expect(html).not.toContain("Infinity");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("renders multiplier row when costMultiplier is finite and != 1", () => {
|
|
|
+ const html = renderWithIntl(
|
|
|
+ <ErrorDetailsDialog
|
|
|
+ externalOpen
|
|
|
+ statusCode={500}
|
|
|
+ errorMessage={null}
|
|
|
+ providerChain={null}
|
|
|
+ sessionId={null}
|
|
|
+ costUsd={"0.000001"}
|
|
|
+ costMultiplier={"0.2"}
|
|
|
+ inputTokens={100}
|
|
|
+ outputTokens={80}
|
|
|
+ />
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(html).toContain("Multiplier");
|
|
|
+ expect(html).toContain("0.20x");
|
|
|
+ });
|
|
|
});
|