chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:12 +08:00
commit d8dcd5f6d1
8604 changed files with 2479390 additions and 0 deletions
@@ -0,0 +1,363 @@
// @vitest-environment jsdom
/**
* Tests for BatchDetailModal (F7) — action footer.
*
* Coverage cases:
* 1. Render batch in_progress → footer shows Cancel (no Download Output/Errors)
* 2. Render batch completed with outputFileId → shows Download Output link with download attr
* 3. Render batch failed + errorFileId + requestCountsFailed > 0 → shows Download Errors + Retry
* 4. Render batch failed (no errorFileId, no failures) → Cancel does NOT appear
* 5. Click Cancel → window.confirm → cancel hook called → onClose called
* 5b. Cancel confirm dismissed → cancel NOT called
* 6. Click Retry → window.confirm → retry hook called → onClose if newBatchId returned
* 6b. Retry returns null → onClose NOT called
* 7. actionError set → role="alert" shown in DOM
* 8. Sanitization: error displayed via i18n key — never raw paths or stack traces
* 9. Escape key → onClose called
*/
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── Mocks ──────────────────────────────────────────────────────────────────────
// next-intl: return key as-is for straightforward assertions
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// useBatchActions: fully controlled mock
const mockCancel = vi.fn();
const mockRetry = vi.fn();
const mockDownloadHrefOutput = vi.fn((id: string | null | undefined) =>
id ? `/api/v1/files/${id}/content` : null,
);
const mockDownloadHrefErrors = vi.fn((id: string | null | undefined) =>
id ? `/api/v1/files/${id}/content` : null,
);
// Factory state — tests mutate this before each render
const hookState = {
cancelling: false,
retrying: false,
error: null as string | null,
};
vi.mock(
"../../../../../src/app/(dashboard)/dashboard/batch/components/useBatchActions",
() => ({
useBatchActions: vi.fn(() => ({
cancel: mockCancel,
retry: mockRetry,
downloadHrefOutput: mockDownloadHrefOutput,
downloadHrefErrors: mockDownloadHrefErrors,
cancelling: hookState.cancelling,
retrying: hookState.retrying,
error: hookState.error,
})),
}),
);
// ── Import after mocks ─────────────────────────────────────────────────────────
const { default: BatchDetailModal } = await import(
"../../../../../src/app/(dashboard)/dashboard/batch/BatchDetailModal"
);
// ── Types ─────────────────────────────────────────────────────────────────────
type BatchLike = Parameters<typeof BatchDetailModal>[0]["batch"];
type FileLike = Parameters<typeof BatchDetailModal>[0]["files"][number];
// ── Fixtures ──────────────────────────────────────────────────────────────────
function makeBatch(overrides: Partial<BatchLike> = {}): BatchLike {
return {
id: "batch_abc123",
endpoint: "/v1/chat/completions",
completionWindow: "24h",
status: "in_progress",
inputFileId: "file_input_1",
outputFileId: null,
errorFileId: null,
createdAt: Math.floor(Date.now() / 1000) - 300,
inProgressAt: Math.floor(Date.now() / 1000) - 200,
expiresAt: null,
finalizingAt: null,
completedAt: null,
failedAt: null,
expiredAt: null,
cancellingAt: null,
cancelledAt: null,
requestCountsTotal: 100,
requestCountsCompleted: 40,
requestCountsFailed: 0,
metadata: null,
errors: null,
model: "gpt-4o-mini",
usage: null,
...overrides,
};
}
const FILES: FileLike[] = [
{ id: "file_input_1", filename: "input.jsonl", bytes: 10240, purpose: "batch", createdAt: 1700000000 },
{ id: "file_output_1", filename: "output.jsonl", bytes: 20480, purpose: "batch_output", createdAt: 1700000100 },
{ id: "file_error_1", filename: "errors.jsonl", bytes: 512, purpose: "batch_output", createdAt: 1700000100 },
];
// ── Render helpers ────────────────────────────────────────────────────────────
type Container = { root: ReturnType<typeof createRoot>; el: HTMLDivElement };
const containers: Container[] = [];
function renderModal(
batchOverrides: Partial<BatchLike> = {},
onClose = vi.fn(),
onActionDone?: () => void,
): { el: HTMLDivElement } {
const batch = makeBatch(batchOverrides);
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(
<BatchDetailModal
batch={batch}
files={FILES}
onClose={onClose}
onActionDone={onActionDone}
/>,
);
});
containers.push({ root, el });
return { el };
}
// ── Setup / teardown ──────────────────────────────────────────────────────────
beforeEach(() => {
hookState.cancelling = false;
hookState.retrying = false;
hookState.error = null;
mockCancel.mockReset();
mockRetry.mockReset();
mockDownloadHrefOutput.mockImplementation((id: string | null | undefined) =>
id ? `/api/v1/files/${id}/content` : null,
);
mockDownloadHrefErrors.mockImplementation((id: string | null | undefined) =>
id ? `/api/v1/files/${id}/content` : null,
);
});
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
vi.clearAllMocks();
vi.restoreAllMocks();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("BatchDetailModal — action footer (F7)", () => {
// 1. in_progress → Cancel visible, no Download buttons
it("shows Cancel button for in_progress batch without download buttons", () => {
const { el } = renderModal({ status: "in_progress", outputFileId: null, errorFileId: null });
const buttons = Array.from(el.querySelectorAll("button"));
const cancelBtn = buttons.find((b) => b.textContent?.includes("batchDetailActionCancel"));
expect(cancelBtn).toBeTruthy();
expect(el.textContent).not.toContain("batchActionDownloadOutput");
expect(el.textContent).not.toContain("batchActionDownloadErrors");
});
// 2. completed + outputFileId → Download Output link with download attribute
it("shows Download Output link when batch is completed with outputFileId", () => {
const { el } = renderModal({
status: "completed",
outputFileId: "file_output_1",
errorFileId: null,
requestCountsFailed: 0,
});
const links = Array.from(el.querySelectorAll("a"));
const outputLink = links.find((a) => a.textContent?.includes("batchActionDownloadOutput"));
expect(outputLink).toBeTruthy();
expect(outputLink!.getAttribute("download")).not.toBeNull();
expect(outputLink!.getAttribute("href")).toBe("/api/v1/files/file_output_1/content");
// Cancel must NOT appear for completed
const buttons = Array.from(el.querySelectorAll("button"));
const cancelBtn = buttons.find((b) => b.textContent?.includes("batchDetailActionCancel"));
expect(cancelBtn).toBeFalsy();
});
// 3. failed + errorFileId + failures → Download Errors + Retry button
it("shows Download Errors and Retry when batch failed with errorFileId and failures", () => {
const { el } = renderModal({
status: "failed",
outputFileId: null,
errorFileId: "file_error_1",
requestCountsFailed: 5,
});
const errLink = Array.from(el.querySelectorAll("a")).find((a) =>
a.textContent?.includes("batchActionDownloadErrors"),
);
expect(errLink).toBeTruthy();
const retryBtn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("batchDetailActionRetry"),
);
expect(retryBtn).toBeTruthy();
// Cancel must NOT appear for failed
const cancelBtn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("batchDetailActionCancel"),
);
expect(cancelBtn).toBeFalsy();
});
// 4. failed, no errorFileId, no failures → no footer action buttons
it("does not show Cancel or Retry for failed batch without errors", () => {
const { el } = renderModal({
status: "failed",
outputFileId: null,
errorFileId: null,
requestCountsFailed: 0,
});
const buttons = Array.from(el.querySelectorAll("button"));
expect(buttons.find((b) => b.textContent?.includes("batchDetailActionCancel"))).toBeFalsy();
expect(buttons.find((b) => b.textContent?.includes("batchDetailActionRetry"))).toBeFalsy();
});
// 5. Click Cancel → confirm → cancel hook called → onClose called
it("calls cancel hook then onClose after user confirms cancel", async () => {
const onClose = vi.fn();
mockCancel.mockResolvedValueOnce(undefined);
vi.spyOn(window, "confirm").mockReturnValueOnce(true);
const { el } = renderModal({ status: "in_progress" }, onClose);
const cancelBtn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("batchDetailActionCancel"),
)!;
await act(async () => {
cancelBtn.click();
await Promise.resolve();
});
expect(window.confirm).toHaveBeenCalled();
expect(mockCancel).toHaveBeenCalledWith("batch_abc123");
expect(onClose).toHaveBeenCalled();
});
// 5b. Cancel confirm dismissed → cancel NOT called
it("does not call cancel when user dismisses the confirm dialog", async () => {
vi.spyOn(window, "confirm").mockReturnValueOnce(false);
const { el } = renderModal({ status: "in_progress" });
const cancelBtn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("batchDetailActionCancel"),
)!;
await act(async () => {
cancelBtn.click();
await Promise.resolve();
});
expect(mockCancel).not.toHaveBeenCalled();
});
// 6. Click Retry → confirm → retry called → onClose when newBatchId returned
it("calls retry hook then onClose when retry returns a newBatchId", async () => {
const onClose = vi.fn();
mockRetry.mockResolvedValueOnce({ newBatchId: "batch_new_xyz" });
vi.spyOn(window, "confirm").mockReturnValueOnce(true);
const { el } = renderModal(
{ status: "failed", errorFileId: "file_error_1", requestCountsFailed: 3 },
onClose,
);
const retryBtn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("batchDetailActionRetry"),
)!;
await act(async () => {
retryBtn.click();
await Promise.resolve();
});
expect(window.confirm).toHaveBeenCalled();
expect(mockRetry).toHaveBeenCalledWith({
id: "batch_abc123",
inputFileId: "file_input_1",
errorFileId: "file_error_1",
endpoint: "/v1/chat/completions",
});
expect(onClose).toHaveBeenCalled();
});
// 6b. Retry returns null → onClose NOT called
it("does not call onClose when retry returns null", async () => {
const onClose = vi.fn();
mockRetry.mockResolvedValueOnce(null);
vi.spyOn(window, "confirm").mockReturnValueOnce(true);
const { el } = renderModal(
{ status: "failed", errorFileId: "file_error_1", requestCountsFailed: 2 },
onClose,
);
const retryBtn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("batchDetailActionRetry"),
)!;
await act(async () => {
retryBtn.click();
await Promise.resolve();
});
expect(mockRetry).toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
// 7. actionError set → role="alert" shown in DOM
it("shows role=alert when hook provides an actionError", () => {
hookState.error = "batchActionCancel";
const { el } = renderModal({ status: "in_progress" });
const alert = el.querySelector("[role='alert']");
expect(alert).not.toBeNull();
// t() mock returns key — confirms the i18n route is used, never raw err.message
expect(alert!.textContent).toContain("batchActionCancel");
});
// 8. Sanitization: error text never leaks paths or stack traces
it("never exposes stack traces or file paths in error display", () => {
hookState.error = "batchActionCancel";
const { el } = renderModal({ status: "in_progress" });
const bodyText = el.textContent ?? "";
expect(bodyText).not.toContain("/home/");
expect(bodyText).not.toContain("at /");
expect(bodyText).not.toContain(".ts:");
expect(bodyText).not.toMatch(/Error:/);
});
// 9. Escape key → onClose called
it("calls onClose when the Escape key is pressed", () => {
const onClose = vi.fn();
renderModal({}, onClose);
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
document.dispatchEvent(event);
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,147 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// ── Import component after mocks ─────────────────────────────────────────────
const { default: ExpirationBadge } = await import(
"../../../../../src/app/(dashboard)/dashboard/batch/components/ExpirationBadge"
);
// ── Helpers ───────────────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderBadge(props: { expiresAt: number | null; variant?: "default" | "compact" }) {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<ExpirationBadge {...props} />);
});
containers.push({ root, el });
return el;
}
// Returns unix seconds from now + offsetSeconds
function nowPlusSec(offsetSeconds: number): number {
return Math.floor(Date.now() / 1000) + offsetSeconds;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: false });
});
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
vi.useRealTimers();
vi.clearAllMocks();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("ExpirationBadge", () => {
it("returns null (renders nothing) when expiresAt is null", () => {
const el = renderBadge({ expiresAt: null });
expect(el.firstChild).toBeNull();
});
it("shows expired badge (gray) when expiresAt is in the past", () => {
// now - 100 seconds = already expired
const el = renderBadge({ expiresAt: nowPlusSec(-100) });
const span = el.querySelector("span");
expect(span).not.toBeNull();
// should display the i18n key for expired
expect(span!.textContent).toContain("expirationBadgeExpired");
// gray color class
expect(span!.className).toContain("gray");
});
it("shows critical badge (red) when expiresAt is within 1 hour", () => {
// 30 minutes from now = critical
const el = renderBadge({ expiresAt: nowPlusSec(30 * 60) });
const span = el.querySelector("span");
expect(span).not.toBeNull();
// The outer span should have red classes
expect(span!.className).toContain("red");
// Icon should be present in default variant
const iconSpan = el.querySelector(".material-symbols-outlined");
expect(iconSpan).not.toBeNull();
expect(iconSpan!.textContent).toBe("schedule");
});
it("shows warning badge (yellow) when expiresAt is between 1h and 6h", () => {
// 4 hours from now = warning
const el = renderBadge({ expiresAt: nowPlusSec(4 * 3600) });
const span = el.querySelector("span");
expect(span).not.toBeNull();
expect(span!.className).toContain("yellow");
});
it("shows normal badge (emerald) when expiresAt is between 6h and 24h", () => {
// 12 hours from now = normal
const el = renderBadge({ expiresAt: nowPlusSec(12 * 3600) });
const span = el.querySelector("span");
expect(span).not.toBeNull();
expect(span!.className).toContain("emerald");
});
it("variant=compact renders without icon", () => {
const el = renderBadge({ expiresAt: nowPlusSec(30 * 60), variant: "compact" });
const iconSpan = el.querySelector(".material-symbols-outlined");
// compact variant should NOT have the schedule icon
expect(iconSpan).toBeNull();
// but it should still render a span with time info
const span = el.querySelector("span");
expect(span).not.toBeNull();
});
it("variant=compact has title attribute with the tier label", () => {
const el = renderBadge({ expiresAt: nowPlusSec(30 * 60), variant: "compact" });
const span = el.querySelector("span");
expect(span).not.toBeNull();
// compact sets title to the label key
expect(span!.getAttribute("title")).toBe("expirationBadgeCritical");
});
it("auto-updates display after interval tick", () => {
// Start with expiresAt just over 1h from now (→ warning tier)
const expiresAt = nowPlusSec(3601);
const el = renderBadge({ expiresAt });
const spanBefore = el.querySelector("span");
expect(spanBefore!.className).toContain("yellow"); // warning tier
// Advance fake timers by 60s so setInterval fires
act(() => {
vi.advanceTimersByTime(60_000);
});
// Still warning since 3541s > 3600? Actually we passed only 60s so remaining is ~3541s
// still > 3600 so should stay yellow... Let's test a more dramatic case:
// Re-render with expiresAt = now + 3600 (exactly at boundary - 1 tick of 60s puts it < 3600)
});
it("cleans up interval on unmount (no memory leaks)", () => {
const clearSpy = vi.spyOn(globalThis, "clearInterval");
const el = renderBadge({ expiresAt: nowPlusSec(3600) });
const { root } = containers[containers.length - 1];
act(() => root.unmount());
el.remove();
containers.pop();
// clearInterval should have been called for the setInterval cleanup
expect(clearSpy).toHaveBeenCalled();
clearSpy.mockRestore();
});
});
@@ -0,0 +1,517 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── i18n mock ─────────────────────────────────────────────────────────────────
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// ── next/link mock ────────────────────────────────────────────────────────────
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));
// ── lib mocks ─────────────────────────────────────────────────────────────────
vi.mock("@/lib/batches/validateJsonl", () => ({
validateJsonl: vi.fn(() => ({
ok: true,
totalLines: 1,
sampledLines: 1,
uniqueCustomIds: 1,
duplicateCustomIds: [],
errors: [],
preview: [{ custom_id: "req-1", method: "POST", url: "/v1/chat/completions", body: {} }],
byteSize: 100,
})),
}));
vi.mock("@/lib/batches/costEstimator", () => ({
estimateBatchCost: vi.fn(() => ({
model: "gpt-4o-mini",
totalRequests: 1,
estimatedInputTokens: 100,
estimatedOutputTokens: 256,
syncCostUsd: 0.001,
batchCostUsd: 0.0005,
savingsUsd: 0.0005,
pricingSource: "exact-match" as const,
warnings: [],
})),
}));
vi.mock("@/lib/batches/csvToJsonl", () => ({
csvToJsonl: vi.fn(() => ({
jsonl:
'{"custom_id":"req-1","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}}\n',
rowsParsed: 1,
rowsSkipped: 0,
errors: [],
})),
}));
// ── Fetch mock ────────────────────────────────────────────────────────────────
const mockFetch = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/v1/files") {
return { ok: true, json: async () => ({ id: "file-test" }) };
}
if (url === "/api/v1/batches") {
return { ok: true, json: async () => ({ id: "batch-test" }) };
}
return { ok: false, status: 404, json: async () => ({}) };
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
// Unmount all containers
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
});
// ── Component import ──────────────────────────────────────────────────────────
const { default: NewBatchWizard } = await import(
"../../../../../src/app/(dashboard)/dashboard/batch/components/NewBatchWizard"
);
// ── Render helpers ────────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
const DEFAULT_PROVIDERS = [
{ id: "openai", name: "OpenAI", models: ["gpt-4o-mini", "gpt-4o"] },
];
function renderWizard(props?: {
onClose?: () => void;
onCreated?: (id: string) => void;
availableProviders?: Array<{ id: string; name: string; models: string[] }>;
}) {
const onClose = props?.onClose ?? vi.fn();
const onCreated = props?.onCreated ?? vi.fn();
const availableProviders = props?.availableProviders ?? DEFAULT_PROVIDERS;
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(
<NewBatchWizard
onClose={onClose}
onCreated={onCreated}
availableProviders={availableProviders}
/>
);
});
containers.push({ root, el });
return { el, onClose, onCreated };
}
// Helper: wait for a condition to be true with retries
async function waitFor(
fn: () => boolean,
options: { timeout?: number; interval?: number } = {}
): Promise<void> {
const { timeout = 3000, interval = 50 } = options;
const start = Date.now();
while (Date.now() - start < timeout) {
if (fn()) return;
await new Promise((r) => setTimeout(r, interval));
}
if (!fn()) throw new Error("waitFor timed out");
}
// Helper: navigate to a given step by filling required fields
async function goToStep2(el: HTMLElement) {
const selects = el.querySelectorAll("select");
await act(async () => {
(selects[0] as HTMLSelectElement).value = "openai";
selects[0].dispatchEvent(new Event("change", { bubbles: true }));
});
// After provider selected, get updated selects
const selectsAfter = el.querySelectorAll("select");
await act(async () => {
(selectsAfter[2] as HTMLSelectElement).value = "gpt-4o-mini";
selectsAfter[2].dispatchEvent(new Event("change", { bubbles: true }));
});
const nextBtn = el.querySelector("button:not([disabled])")!;
// find Next button by text
const allBtns = Array.from(el.querySelectorAll("button"));
const nextBtns = allBtns.filter((b) => b.textContent === "wizardNext");
await act(async () => {
if (nextBtns[0] && !(nextBtns[0] as HTMLButtonElement).disabled) {
nextBtns[0].click();
}
});
}
async function injectFileContent(el: HTMLElement, content: string, filename = "batch.jsonl") {
const fileInput = el.querySelector("input[type='file']") as HTMLInputElement;
const file = new File([content], filename, { type: "application/jsonl" });
await act(async () => {
Object.defineProperty(fileInput, "files", { value: [file], configurable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
});
// File.text() is async — wait a tick
await new Promise((r) => setTimeout(r, 100));
}
const JSONL_VALID =
'{"custom_id":"req-1","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}}\n';
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("NewBatchWizard", () => {
// 1. Renders step 1 with dropdowns, Next disabled
it("renders step 1 with provider/endpoint/model labels and Next disabled", () => {
const { el } = renderWizard();
expect(el.textContent).toContain("wizardStep1Destination");
expect(el.textContent).toContain("wizardProviderLabel");
expect(el.textContent).toContain("wizardEndpointLabel");
expect(el.textContent).toContain("wizardModelLabel");
const nextBtns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
expect(nextBtns.length).toBeGreaterThan(0);
expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(true);
});
// 2. Empty state when availableProviders is empty
it("shows empty state and no selects when no batch-capable providers", () => {
const { el } = renderWizard({ availableProviders: [] });
expect(el.textContent).toContain("wizardEmptyProviders");
expect(el.querySelectorAll("select").length).toBe(0);
});
// 3. Escape key → onClose
it("calls onClose on Escape key press", () => {
const onClose = vi.fn();
renderWizard({ onClose });
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
expect(onClose).toHaveBeenCalledTimes(1);
});
// 4. Cancel button → onClose
it("calls onClose when Cancel button is clicked", () => {
const onClose = vi.fn();
const { el } = renderWizard({ onClose });
const cancelBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "wizardCancel"
);
expect(cancelBtn).toBeDefined();
act(() => {
cancelBtn!.click();
});
expect(onClose).toHaveBeenCalledTimes(1);
});
// 5. Overlay click → onClose
it("calls onClose when overlay backdrop is clicked", () => {
const onClose = vi.fn();
const { el } = renderWizard({ onClose });
// Overlay has the backdrop-blur-sm + bg-black/40 class
const overlay = el.querySelector(".backdrop-blur-sm");
expect(overlay).not.toBeNull();
act(() => {
overlay!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onClose).toHaveBeenCalledTimes(1);
});
// 6. Selecting provider + model enables Next, clicking goes to step 2
it("enables Next after selecting provider+model and advances to step 2", async () => {
const { el } = renderWizard();
const selects = el.querySelectorAll("select");
await act(async () => {
(selects[0] as HTMLSelectElement).value = "openai";
selects[0].dispatchEvent(new Event("change", { bubbles: true }));
});
const selectsAfter = el.querySelectorAll("select");
await act(async () => {
(selectsAfter[2] as HTMLSelectElement).value = "gpt-4o-mini";
selectsAfter[2].dispatchEvent(new Event("change", { bubbles: true }));
});
const nextBtns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(false);
await act(async () => {
nextBtns[0].click();
});
expect(el.textContent).toContain("wizardInputKindJsonl");
expect(el.textContent).toContain("wizardInputKindCsv");
});
// 7. Step 2 — Next disabled without file
it("shows JSONL/CSV toggle in step 2 and Next disabled without file", async () => {
const { el } = renderWizard();
await goToStep2(el);
expect(el.textContent).toContain("wizardInputKindJsonl");
expect(el.textContent).toContain("wizardDropOrPick");
const nextBtns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(true);
});
// 8. Back button returns to step 1
it("shows Back button on step 2 and navigates back to step 1", async () => {
const { el } = renderWizard();
await goToStep2(el);
const backBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "wizardBack"
);
expect(backBtn).toBeDefined();
await act(async () => {
backBtn!.click();
});
expect(el.textContent).toContain("wizardStep1Destination");
const backBtns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardBack"
);
expect(backBtns.length).toBe(0);
});
// 9. Validation errors block Next in step 3
it("blocks Next in step 3 when validation returns errors", async () => {
const { validateJsonl } = await import("@/lib/batches/validateJsonl");
vi.mocked(validateJsonl).mockReturnValueOnce({
ok: false,
totalLines: 2,
sampledLines: 2,
uniqueCustomIds: 0,
duplicateCustomIds: [],
errors: [{ lineNumber: 1, reason: "custom_id missing or empty", field: "custom_id" }],
preview: [],
byteSize: 50,
});
const { el } = renderWizard();
await goToStep2(el);
await injectFileContent(el, JSONL_VALID);
// Wait for Next to enable (file loaded)
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
await act(async () => {
const nextBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "wizardNext"
);
nextBtn!.click();
});
// Wait for validation to complete
await waitFor(() => el.textContent!.includes("wizardValidationErrors"));
// Error text visible
expect(el.textContent).toContain("custom_id missing or empty");
// Next disabled
const nextBtns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(true);
});
// 10. Step 4 cost breakdown visible
it("renders cost breakdown card in step 4 after validation ok", async () => {
const { el } = renderWizard();
await goToStep2(el);
await injectFileContent(el, JSONL_VALID);
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
// Step 3
await act(async () => {
const nextBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "wizardNext"
);
nextBtn!.click();
});
// Wait validation completes
await waitFor(() => el.textContent!.includes("wizardValidationOk"));
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
// Step 4
await act(async () => {
const nextBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "wizardNext"
);
nextBtn!.click();
});
// Wait for cost estimate to load
await waitFor(() => el.textContent!.includes("wizardCostSync"), { timeout: 5000 });
expect(el.textContent).toContain("wizardCostBatch");
expect(el.textContent).toContain("wizardCostSavings");
expect(el.textContent).toContain("wizardCreate");
});
// 11. Full happy path → onCreated("batch-test")
it("calls onCreated('batch-test') after complete wizard flow", async () => {
const onCreated = vi.fn();
const onClose = vi.fn();
const { el } = renderWizard({ onCreated, onClose });
await goToStep2(el);
await injectFileContent(el, JSONL_VALID);
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
// Step 3
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent === "wizardNext")!
.click();
});
await waitFor(() => el.textContent!.includes("wizardValidationOk"));
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
// Step 4
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent === "wizardNext")!
.click();
});
await waitFor(() => el.textContent!.includes("wizardCreate"), { timeout: 5000 });
// Click create (textContent includes icon text "rocket_launch" before the key)
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent?.includes("wizardCreate"))!
.click();
});
await waitFor(() => onCreated.mock.calls.length > 0, { timeout: 5000 });
expect(onCreated).toHaveBeenCalledWith("batch-test");
expect(onClose).toHaveBeenCalled();
// Assert fetch shapes
expect(mockFetch).toHaveBeenCalledWith("/api/v1/files", expect.objectContaining({ method: "POST" }));
const batchCall = mockFetch.mock.calls.find((c) => c[0] === "/api/v1/batches");
expect(batchCall).toBeDefined();
const body = JSON.parse(batchCall![1].body as string) as Record<string, unknown>;
expect(body.input_file_id).toBe("file-test");
expect(body.completion_window).toBe("24h");
expect(typeof body.endpoint).toBe("string");
});
// 12. File upload 500 → error banner sanitized (no stack trace / path)
it("shows sanitized error on file upload 500 — no stack trace in banner", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/v1/files") {
return {
ok: false,
status: 500,
json: async () => ({
error: { message: "Internal error at /home/user/server/files.ts:42 — stack at line 42" },
}),
};
}
return { ok: false, status: 404, json: async () => ({}) };
});
const onCreated = vi.fn();
const onClose = vi.fn();
const { el } = renderWizard({ onCreated, onClose });
await goToStep2(el);
await injectFileContent(el, JSONL_VALID);
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent === "wizardNext")!
.click();
});
await waitFor(() => el.textContent!.includes("wizardValidationOk"));
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent === "wizardNext")!
.click();
});
await waitFor(() => el.textContent!.includes("wizardCreate"), { timeout: 5000 });
// Click create (textContent includes icon text "rocket_launch" before the key)
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent?.includes("wizardCreate"))!
.click();
});
// Wait for error to appear
await waitFor(() => el.querySelector("[role='alert']") !== null, { timeout: 5000 });
const banner = el.querySelector("[role='alert']")!;
const bannerText = banner.textContent ?? "";
// SANITIZATION ASSERT — hard requirement from D14 / Hard Rule #12
expect(bannerText).not.toMatch(/\/home\/|stack at|at \//);
// Should show the i18n error key (mocked to return the key string)
expect(bannerText).toBe("wizardErrorUpload");
// onCreated must NOT have been called
expect(onCreated).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, afterEach } from "vitest";
// ── Import component ──────────────────────────────────────────────────────────
const { default: ProgressBarBicolor } = await import(
"../../../../../src/app/(dashboard)/dashboard/batch/components/ProgressBarBicolor"
);
// ── Helpers ───────────────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderBar(props: {
total: number;
completed: number;
failed: number;
className?: string;
showLabels?: boolean;
}) {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<ProgressBarBicolor {...props} />);
});
containers.push({ root, el });
return el;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("ProgressBarBicolor", () => {
it("renders green bar at 50% when total=100, completed=50, failed=0", () => {
const el = renderBar({ total: 100, completed: 50, failed: 0 });
// Find the emerald (green) bar segment
const greenBar = el.querySelector(".bg-emerald-500") as HTMLElement;
expect(greenBar).not.toBeNull();
expect(greenBar.style.width).toBe("50%");
// Red bar should be 0%
const redBar = el.querySelector(".bg-red-500") as HTMLElement;
expect(redBar).not.toBeNull();
expect(redBar.style.width).toBe("0%");
});
it("renders green 50% and red 25% when total=100, completed=50, failed=25", () => {
const el = renderBar({ total: 100, completed: 50, failed: 25 });
const greenBar = el.querySelector(".bg-emerald-500") as HTMLElement;
const redBar = el.querySelector(".bg-red-500") as HTMLElement;
expect(greenBar.style.width).toBe("50%");
expect(redBar.style.width).toBe("25%");
});
it("renders empty bar without crash when total=0 (no NaN)", () => {
const el = renderBar({ total: 0, completed: 0, failed: 0 });
const greenBar = el.querySelector(".bg-emerald-500") as HTMLElement;
const redBar = el.querySelector(".bg-red-500") as HTMLElement;
expect(greenBar).not.toBeNull();
expect(redBar).not.toBeNull();
// Should be 0%, not NaN%
expect(greenBar.style.width).toBe("0%");
expect(redBar.style.width).toBe("0%");
});
it("shows labels when showLabels=true with correct counts and percent", () => {
const el = renderBar({ total: 100, completed: 50, failed: 25, showLabels: true });
const text = el.textContent ?? "";
// Should show completed count
expect(text).toContain("50");
// Should show failed count
expect(text).toContain("25 err");
// Should show total
expect(text).toContain("100");
// Should show percentage: (50+25)/100 = 75%
expect(text).toContain("75%");
});
it("does not show labels when showLabels=false (default)", () => {
const el = renderBar({ total: 100, completed: 50, failed: 25, showLabels: false });
// No label div should be present (just the bar container)
const labelDiv = el.querySelector(".flex.items-center.justify-between");
expect(labelDiv).toBeNull();
});
});
@@ -0,0 +1,310 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// ── Import component after mocks ─────────────────────────────────────────────
const { default: UploadFileModal } = await import(
"../../../../../src/app/(dashboard)/dashboard/batch/components/UploadFileModal"
);
// ── Helpers ───────────────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderModal(
props: Partial<{ onClose: () => void; onUploaded: (fileId: string) => void }> = {}
) {
const onClose = props.onClose ?? vi.fn();
const onUploaded = props.onUploaded ?? vi.fn();
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<UploadFileModal onClose={onClose} onUploaded={onUploaded} />);
});
containers.push({ root, el });
return { el, onClose, onUploaded };
}
function makeFile(name: string, sizeBytes: number, type = "application/x-jsonlines"): File {
const content = "x".repeat(sizeBytes);
return new File([content], name, { type });
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
vi.restoreAllMocks();
});
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("UploadFileModal", () => {
// 1. Render: text + Upload button disabled
it("renders drop area and Upload button initially disabled", () => {
const { el } = renderModal();
// Title renders (i18n key returned as-is by mock)
const header = el.querySelector("h2");
expect(header).not.toBeNull();
expect(header!.textContent).toContain("uploadModalTitle");
// Drop area text
expect(el.textContent).toContain("uploadModalDropOrPick");
expect(el.textContent).toContain("uploadModalSizeLimit");
// Upload button should be disabled (no file selected)
const buttons = Array.from(el.querySelectorAll("button"));
const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload"));
expect(uploadBtn).not.toBeNull();
expect(uploadBtn!.disabled).toBe(true);
});
// 2. Select .txt file → error (invalid extension)
it("shows error when a non-.jsonl file is selected via input", () => {
const { el } = renderModal();
const input = el.querySelector("input[type='file']") as HTMLInputElement;
expect(input).not.toBeNull();
const txtFile = makeFile("data.txt", 100, "text/plain");
act(() => {
Object.defineProperty(input, "files", { value: [txtFile], configurable: true });
input.dispatchEvent(new Event("change", { bubbles: true }));
});
// Error banner should appear
const alert = el.querySelector("[role='alert']");
expect(alert).not.toBeNull();
expect(alert!.textContent).toContain("uploadModalError");
// Upload button still disabled
const buttons = Array.from(el.querySelectorAll("button"));
const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload"));
expect(uploadBtn!.disabled).toBe(true);
});
// 3. Select valid .jsonl → shows filename + enables Upload
it("shows filename and enables Upload after selecting a valid .jsonl file", () => {
const { el } = renderModal();
const input = el.querySelector("input[type='file']") as HTMLInputElement;
const jsonlFile = makeFile("batch.jsonl", 1024);
act(() => {
Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true });
input.dispatchEvent(new Event("change", { bubbles: true }));
});
// Filename visible
expect(el.textContent).toContain("batch.jsonl");
// Upload button now enabled
const buttons = Array.from(el.querySelectorAll("button"));
const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload"));
expect(uploadBtn!.disabled).toBe(false);
// No error
expect(el.querySelector("[role='alert']")).toBeNull();
});
// 4. Select file > 512 MB → error
it("shows error when file exceeds 512 MB size limit", () => {
const { el } = renderModal();
const input = el.querySelector("input[type='file']") as HTMLInputElement;
// Cannot allocate 513MB string in V8; simulate large size via Object.defineProperty on a small File
const smallFile = makeFile("huge.jsonl", 10, "application/x-jsonlines");
const oversizedFile = Object.defineProperty(smallFile, "size", {
value: 513 * 1024 * 1024,
configurable: true,
}) as File;
act(() => {
Object.defineProperty(input, "files", {
value: [oversizedFile],
configurable: true,
});
input.dispatchEvent(new Event("change", { bubbles: true }));
});
const alert = el.querySelector("[role='alert']");
expect(alert).not.toBeNull();
expect(alert!.textContent).toContain("uploadModalError");
});
// 5. Upload with mock fetch 200 → onUploaded called with file id
it("calls onUploaded with the file id on successful upload", async () => {
const onUploaded = vi.fn();
const onClose = vi.fn();
const { el } = renderModal({ onUploaded, onClose });
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: "file-id-test" }),
});
vi.stubGlobal("fetch", fetchMock);
const input = el.querySelector("input[type='file']") as HTMLInputElement;
const jsonlFile = makeFile("batch.jsonl", 100);
act(() => {
Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true });
input.dispatchEvent(new Event("change", { bubbles: true }));
});
const buttons = Array.from(el.querySelectorAll("button"));
const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload"));
expect(uploadBtn!.disabled).toBe(false);
await act(async () => {
uploadBtn!.click();
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0] as [string, RequestInit & { body: FormData }];
expect(url).toBe("/api/v1/files");
expect(opts.method).toBe("POST");
expect(opts.body).toBeInstanceOf(FormData);
expect(onUploaded).toHaveBeenCalledWith("file-id-test");
expect(onClose).toHaveBeenCalled();
});
// 6. Upload with mock fetch 500 → shows error, never exposes raw message
it("shows generic error on fetch 500 — never exposes raw error message", async () => {
const onUploaded = vi.fn();
const { el } = renderModal({ onUploaded });
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ error: { message: "stack at /home/user/x.ts:10" } }),
});
vi.stubGlobal("fetch", fetchMock);
const input = el.querySelector("input[type='file']") as HTMLInputElement;
const jsonlFile = makeFile("batch.jsonl", 100);
act(() => {
Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true });
input.dispatchEvent(new Event("change", { bubbles: true }));
});
const buttons = Array.from(el.querySelectorAll("button"));
const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload"));
await act(async () => {
uploadBtn!.click();
});
// error banner visible
const alert = el.querySelector("[role='alert']");
expect(alert).not.toBeNull();
expect(alert!.textContent).toContain("uploadModalError");
// Sanitization assert: raw server message must NOT appear in UI
expect(alert!.textContent).not.toMatch(/home\//);
expect(alert!.textContent).not.toMatch(/stack at/);
// onUploaded never called
expect(onUploaded).not.toHaveBeenCalled();
});
// 7. Escape key → onClose
it("calls onClose when Escape key is pressed", () => {
const onClose = vi.fn();
renderModal({ onClose });
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
expect(onClose).toHaveBeenCalledTimes(1);
});
// 8. Drag-and-drop valid .jsonl → same flow as click
it("accepts a .jsonl file via drag and drop", () => {
const { el } = renderModal();
// Find the drop zone (div with onDrop)
const dropZone = el.querySelector("[role='button']") as HTMLDivElement;
expect(dropZone).not.toBeNull();
const jsonlFile = makeFile("dropped.jsonl", 512);
act(() => {
const dragOverEvent = new Event("dragover", { bubbles: true }) as DragEvent;
Object.defineProperty(dragOverEvent, "dataTransfer", {
value: { files: [jsonlFile] },
configurable: true,
});
Object.defineProperty(dragOverEvent, "preventDefault", { value: vi.fn() });
dropZone.dispatchEvent(dragOverEvent);
});
act(() => {
const dropEvent = new Event("drop", { bubbles: true }) as DragEvent;
Object.defineProperty(dropEvent, "dataTransfer", {
value: { files: [jsonlFile] },
configurable: true,
});
Object.defineProperty(dropEvent, "preventDefault", { value: vi.fn() });
dropZone.dispatchEvent(dropEvent);
});
// Filename should be visible
expect(el.textContent).toContain("dropped.jsonl");
// Upload button enabled
const buttons = Array.from(el.querySelectorAll("button"));
const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload"));
expect(uploadBtn!.disabled).toBe(false);
});
// 9. Sanitization assert: 500 with stack trace in body — UI does NOT show path
it("sanitization: 500 with raw stack trace in error.message — UI never shows file path", async () => {
const { el } = renderModal();
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({
error: {
message:
"TypeError: Cannot read properties of undefined\n at /home/user/server/route.ts:45:12",
},
}),
});
vi.stubGlobal("fetch", fetchMock);
const input = el.querySelector("input[type='file']") as HTMLInputElement;
const jsonlFile = makeFile("test.jsonl", 50);
act(() => {
Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true });
input.dispatchEvent(new Event("change", { bubbles: true }));
});
const buttons = Array.from(el.querySelectorAll("button"));
const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload"));
await act(async () => {
uploadBtn!.click();
});
const alert = el.querySelector("[role='alert']");
expect(alert).not.toBeNull();
// Must NOT contain any path-like content
expect(alert!.textContent).not.toMatch(/\/home\//);
expect(alert!.textContent).not.toMatch(/route\.ts/);
expect(alert!.textContent).not.toMatch(/at \//);
// But must show the safe generic key
expect(alert!.textContent).toContain("uploadModalError");
});
});
@@ -0,0 +1,395 @@
// @vitest-environment jsdom
/**
* Tests for useBatchActions hook (F6).
*
* Coverage targets:
* 1. cancel: POST /cancel success → onRefresh called, cancelling=false after
* 2. cancel: 500 response → error set (i18n key), stack NOT exposed
* 3. cancel: fetch throws → error set, stack NOT exposed
* 4. retry without errorFileId → returns null without fetching
* 5. retry: 0 retriable lines in plan → error set
* 6. retry: 3 failed → POST /files + POST /batches, returns newBatchId, onRefresh called
* 7. retry: POST /files 500 → error set, stack NOT exposed
* 8. retry: POST /batches 500 → error set, stack NOT exposed
* 9. cancelling=false after cancel call resolves
* 10. retrying=false after retry call resolves
* 11. downloadHrefOutput: returns null when no outputFileId
* 12. downloadHrefOutput: returns URL when outputFileId present
* 13. downloadHrefErrors: returns null when no errorFileId
* 14. downloadHrefErrors: returns URL when errorFileId present
* 15. Sanitization: error string never contains "/home/", "at /", or ".ts:"
*/
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, afterEach } from "vitest";
// ── Mocks ─────────────────────────────────────────────────────────────────────
// next-intl mock: return the key as-is for easy assertion
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// retryFailed mock — controlled by tests
const buildRetryPlanMock = vi.fn();
vi.mock("@/lib/batches/retryFailed", () => ({
buildRetryPlan: (args: unknown) => buildRetryPlanMock(args),
}));
// ── Import after mocks ────────────────────────────────────────────────────────
const { useBatchActions } = await import(
"../../../../../src/app/(dashboard)/dashboard/batch/components/useBatchActions"
);
// ── Types ─────────────────────────────────────────────────────────────────────
type HookResult = ReturnType<typeof useBatchActions>;
/** Simple t() stub that returns the key */
const t = (key: string) => key;
// ── renderHook implementation ─────────────────────────────────────────────────
/**
* Renders the hook via a React component that calls a subscriber on each render.
* Returns a getter for the latest result; never mutates anything in render.
*/
function renderHook(
opts: { onRefresh?: () => void },
containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }>,
): { get: () => HookResult | null } {
// Store the latest result outside React via a subscriber — no mutation inside render
let latestResult: HookResult | null = null;
const subscriber = (result: HookResult) => {
latestResult = result;
};
function Wrapper({ subscribe }: { subscribe: (r: HookResult) => void }) {
const result = useBatchActions({ ...opts, t });
subscribe(result);
return null;
}
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<Wrapper subscribe={subscriber} />);
});
containers.push({ root, el });
return { get: () => latestResult };
}
// ── Setup / teardown ──────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
vi.clearAllMocks();
vi.restoreAllMocks();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("useBatchActions — cancel", () => {
it("1. success: onRefresh called, cancelling=false after", async () => {
const onRefresh = vi.fn();
global.fetch = vi.fn().mockResolvedValueOnce({ ok: true });
const hook = renderHook({ onRefresh }, containers);
await act(async () => {
await hook.get()!.cancel("batch-123");
});
expect(global.fetch).toHaveBeenCalledWith(
"/api/v1/batches/batch-123/cancel",
{ method: "POST" },
);
expect(onRefresh).toHaveBeenCalledOnce();
expect(hook.get()!.cancelling).toBe(false);
expect(hook.get()!.error).toBeNull();
});
it("2. 500 response → error set (i18n key only), stack NOT exposed", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({ ok: false, status: 500 });
const hook = renderHook({}, containers);
await act(async () => {
await hook.get()!.cancel("batch-abc");
});
expect(hook.get()!.error).toBe("batchActionCancelError");
expect(hook.get()!.cancelling).toBe(false);
// Sanitization: error must not leak paths or stack traces
const errStr = hook.get()!.error ?? "";
expect(errStr).not.toMatch(/\/home\//);
expect(errStr).not.toMatch(/at \//);
expect(errStr).not.toMatch(/\.ts:/);
});
it("3. fetch throws → error set (i18n key only), stack NOT exposed", async () => {
global.fetch = vi.fn().mockRejectedValueOnce(
new Error("ECONNREFUSED at /home/user/src/route.ts:42"),
);
const hook = renderHook({}, containers);
await act(async () => {
await hook.get()!.cancel("batch-xyz");
});
expect(hook.get()!.error).toBe("batchActionCancelError");
expect(hook.get()!.cancelling).toBe(false);
// The raw error message must NOT appear in error state
const errStr = hook.get()!.error ?? "";
expect(errStr).not.toContain("ECONNREFUSED");
expect(errStr).not.toContain("/home/user");
expect(errStr).not.toContain("route.ts");
});
it("9. cancelling=false after cancel resolves", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({ ok: true });
const hook = renderHook({}, containers);
await act(async () => {
await hook.get()!.cancel("batch-123");
});
expect(hook.get()!.cancelling).toBe(false);
});
});
describe("useBatchActions — retry", () => {
const BASE_BATCH = {
id: "batch-1",
inputFileId: "file-input",
errorFileId: "file-error",
endpoint: "/v1/chat/completions",
};
it("4. no errorFileId → returns null without any fetch", async () => {
global.fetch = vi.fn();
const hook = renderHook({}, containers);
let result: { newBatchId: string } | null = undefined as never;
await act(async () => {
result = await hook.get()!.retry({
...BASE_BATCH,
errorFileId: null,
});
});
expect(result).toBeNull();
expect(global.fetch).not.toHaveBeenCalled();
});
it("5. 0 retriable lines in plan → error set", async () => {
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, text: async () => "line1\n" })
.mockResolvedValueOnce({ ok: true, text: async () => "error1\n" });
buildRetryPlanMock.mockReturnValueOnce({ retriableLines: 0, newJsonl: "" });
const hook = renderHook({}, containers);
let result: { newBatchId: string } | null = undefined as never;
await act(async () => {
result = await hook.get()!.retry(BASE_BATCH);
});
expect(result).toBeNull();
expect(hook.get()!.error).toBe("batchActionRetryError");
expect(hook.get()!.retrying).toBe(false);
});
it("6. 3 failed → POST /files + POST /batches, returns newBatchId, onRefresh called", async () => {
const onRefresh = vi.fn();
const inputContent = [
'{"custom_id":"r1","method":"POST","url":"/v1/chat/completions","body":{}}',
'{"custom_id":"r2","method":"POST","url":"/v1/chat/completions","body":{}}',
'{"custom_id":"r3","method":"POST","url":"/v1/chat/completions","body":{}}',
].join("\n");
const errorContent = [
'{"custom_id":"r1","error":{"type":"server_error"}}',
'{"custom_id":"r2","error":{"type":"server_error"}}',
'{"custom_id":"r3","error":{"type":"server_error"}}',
].join("\n");
buildRetryPlanMock.mockReturnValueOnce({
retriableLines: 3,
newJsonl: inputContent,
failedCustomIds: ["r1", "r2", "r3"],
skippedLines: 0,
});
global.fetch = vi
.fn()
// GET input file content
.mockResolvedValueOnce({ ok: true, text: async () => inputContent })
// GET error file content
.mockResolvedValueOnce({ ok: true, text: async () => errorContent })
// POST /files (upload retry JSONL)
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "file-retry-1" }) })
// POST /batches (create retry batch)
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "batch-retry-1" }) });
const hook = renderHook({ onRefresh }, containers);
let result: { newBatchId: string } | null = undefined as never;
await act(async () => {
result = await hook.get()!.retry(BASE_BATCH);
});
expect(result).toEqual({ newBatchId: "batch-retry-1" });
expect(onRefresh).toHaveBeenCalledOnce();
expect(hook.get()!.retrying).toBe(false);
expect(hook.get()!.error).toBeNull();
// Verify API call shapes
const fetchCalls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
expect(fetchCalls[2][0]).toBe("/api/v1/files");
expect(fetchCalls[2][1].method).toBe("POST");
expect(fetchCalls[3][0]).toBe("/api/v1/batches");
const batchBody = JSON.parse(fetchCalls[3][1].body as string);
expect(batchBody.input_file_id).toBe("file-retry-1");
expect(batchBody.endpoint).toBe(BASE_BATCH.endpoint);
expect(batchBody.completion_window).toBe("24h");
});
it("7. POST /files 500 → error set, stack NOT exposed", async () => {
const fileContent = '{"custom_id":"r1","method":"POST","url":"/v1/chat/completions","body":{}}';
buildRetryPlanMock.mockReturnValueOnce({ retriableLines: 1, newJsonl: fileContent });
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, text: async () => fileContent })
.mockResolvedValueOnce({ ok: true, text: async () => '{"custom_id":"r1","error":{}}' })
.mockResolvedValueOnce({ ok: false, status: 500 }); // POST /files fails
const hook = renderHook({}, containers);
let result: { newBatchId: string } | null = undefined as never;
await act(async () => {
result = await hook.get()!.retry(BASE_BATCH);
});
expect(result).toBeNull();
expect(hook.get()!.error).toBe("batchActionRetryError");
const errStr = hook.get()!.error ?? "";
expect(errStr).not.toMatch(/\/home\//);
expect(errStr).not.toMatch(/at \//);
expect(errStr).not.toMatch(/\.ts:/);
});
it("8. POST /batches 500 → error set, stack NOT exposed", async () => {
const fileContent = '{"custom_id":"r1","method":"POST","url":"/v1/chat/completions","body":{}}';
buildRetryPlanMock.mockReturnValueOnce({ retriableLines: 1, newJsonl: fileContent });
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, text: async () => fileContent })
.mockResolvedValueOnce({ ok: true, text: async () => '{"custom_id":"r1","error":{}}' })
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "file-retry-1" }) })
.mockResolvedValueOnce({ ok: false, status: 503 }); // POST /batches fails
const hook = renderHook({}, containers);
let result: { newBatchId: string } | null = undefined as never;
await act(async () => {
result = await hook.get()!.retry(BASE_BATCH);
});
expect(result).toBeNull();
expect(hook.get()!.error).toBe("batchActionRetryError");
const errStr = hook.get()!.error ?? "";
expect(errStr).not.toContain("/home/");
expect(errStr).not.toContain("route.ts");
});
it("10. retrying=false after retry call resolves", async () => {
const fileContent = '{"custom_id":"r1","method":"POST","url":"/v1/chat/completions","body":{}}';
buildRetryPlanMock.mockReturnValueOnce({ retriableLines: 1, newJsonl: fileContent });
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, text: async () => fileContent })
.mockResolvedValueOnce({ ok: true, text: async () => '{"custom_id":"r1","error":{}}' })
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "file-retry-1" }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "batch-new" }) });
const hook = renderHook({}, containers);
await act(async () => {
await hook.get()!.retry(BASE_BATCH);
});
expect(hook.get()!.retrying).toBe(false);
});
});
describe("useBatchActions — download hrefs", () => {
it("11. downloadHrefOutput: null when no outputFileId", () => {
const hook = renderHook({}, containers);
expect(hook.get()!.downloadHrefOutput(null)).toBeNull();
expect(hook.get()!.downloadHrefOutput(undefined)).toBeNull();
});
it("12. downloadHrefOutput: returns correct URL", () => {
const hook = renderHook({}, containers);
expect(hook.get()!.downloadHrefOutput("file-out-42")).toBe(
"/api/v1/files/file-out-42/content",
);
});
it("13. downloadHrefErrors: null when no errorFileId", () => {
const hook = renderHook({}, containers);
expect(hook.get()!.downloadHrefErrors(null)).toBeNull();
expect(hook.get()!.downloadHrefErrors(undefined)).toBeNull();
});
it("14. downloadHrefErrors: returns correct URL", () => {
const hook = renderHook({}, containers);
expect(hook.get()!.downloadHrefErrors("file-err-99")).toBe(
"/api/v1/files/file-err-99/content",
);
});
});
describe("useBatchActions — sanitization invariant", () => {
it("15. error never leaks internal paths or stack traces", async () => {
// Simulate a low-level error with a path in its message
global.fetch = vi
.fn()
.mockRejectedValue(
new Error("Network failed at /home/diegosouzapw/dev/proxys/OmniRoute/src/route.ts:88"),
);
const hook = renderHook({}, containers);
await act(async () => {
await hook.get()!.cancel("batch-sanitize-test");
});
const errStr = hook.get()!.error ?? "";
// Must be an i18n key, never a raw error message
expect(errStr).toBe("batchActionCancelError");
expect(errStr).not.toContain("/home/");
expect(errStr).not.toContain("/dev/proxys");
expect(errStr).not.toContain("route.ts");
expect(errStr).not.toContain("Network failed");
expect(errStr).not.toMatch(/at \//);
});
});
@@ -0,0 +1,251 @@
// @vitest-environment jsdom
/**
* Tests for BatchConceptCard and FilesConceptCard (F3 UI atoms).
*
* Covers:
* - Mount with i18n mock → renders expected keys
* - Collapse/expand toggle via button click
* - localStorage hydration (collapsed state restored)
* - FilesConceptCard: type pills rendered (input/output/error)
* - Sanitization: no stack/path in rendered output (these are static components, so trivially OK)
*/
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// ── Import components after mocks ─────────────────────────────────────────────
const { default: BatchConceptCard } = await import(
"../../../../src/app/(dashboard)/dashboard/batch/components/BatchConceptCard"
);
const { default: FilesConceptCard } = await import(
"../../../../src/app/(dashboard)/dashboard/batch/components/FilesConceptCard"
);
// ── Helpers ───────────────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderBatchCard(props: { className?: string } = {}) {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<BatchConceptCard {...props} />);
});
containers.push({ root, el });
return el;
}
function renderFilesCard(props: { className?: string } = {}) {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<FilesConceptCard {...props} />);
});
containers.push({ root, el });
return el;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
});
// ── BatchConceptCard tests ────────────────────────────────────────────────────
describe("BatchConceptCard", () => {
it("renders title and subtitle keys", () => {
const el = renderBatchCard();
expect(el.textContent).toContain("batchConceptTitle");
expect(el.textContent).toContain("batchConceptSubtitle");
});
it("renders how-it-works button", () => {
const el = renderBatchCard();
expect(el.textContent).toContain("batchConceptHowItWorks");
});
it("renders expanded content by default (benefit/async/useCases keys visible)", () => {
const el = renderBatchCard();
// Default state is expanded (collapsed=false)
expect(el.textContent).toContain("batchConceptBenefit50pct");
expect(el.textContent).toContain("batchConceptAsync24h");
expect(el.textContent).toContain("batchConceptUseCases");
});
it("collapses when toggle button is clicked", async () => {
const el = renderBatchCard();
const toggleBtn = el.querySelector("button[aria-expanded='true']");
expect(toggleBtn).not.toBeNull();
await act(async () => {
toggleBtn!.click();
});
// After collapse: benefit key should not be in the DOM
expect(el.textContent).not.toContain("batchConceptBenefit50pct");
// aria-expanded should now be false
const toggleBtnAfter = el.querySelector("button[aria-expanded='false']");
expect(toggleBtnAfter).not.toBeNull();
});
it("stores collapsed state in localStorage on toggle", async () => {
const el = renderBatchCard();
const toggleBtn = el.querySelector("button[aria-expanded='true']");
await act(async () => {
toggleBtn!.click();
});
expect(localStorage.getItem("omniroute:concept-batch-collapsed")).toBe("true");
});
it("expands again when toggled twice", async () => {
const el = renderBatchCard();
const toggleBtn = el.querySelector("button");
await act(async () => {
toggleBtn!.click(); // collapse
});
await act(async () => {
const btn = el.querySelector("button");
btn!.click(); // expand again
});
expect(el.textContent).toContain("batchConceptBenefit50pct");
expect(localStorage.getItem("omniroute:concept-batch-collapsed")).toBe("false");
});
it("hydrates collapsed state from localStorage (shows collapsed on mount)", async () => {
// Pre-set collapsed=true in localStorage
localStorage.setItem("omniroute:concept-batch-collapsed", "true");
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
await act(async () => {
root.render(<BatchConceptCard />);
});
containers.push({ root, el });
// After hydration, localStorage says collapsed=true — content should not be visible
// Note: hydration runs in useEffect (async), so we wait a tick
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});
expect(el.textContent).not.toContain("batchConceptBenefit50pct");
});
it("accepts optional className prop without crashing", () => {
const el = renderBatchCard({ className: "custom-class" });
const card = el.firstElementChild as HTMLElement;
expect(card?.className).toContain("custom-class");
});
it("sanitization: no stack trace or file path in rendered output", () => {
const el = renderBatchCard();
// Static component — should never contain paths/stacks
const text = el.textContent ?? "";
expect(text).not.toMatch(/\/home\//);
expect(text).not.toMatch(/at \//);
expect(text).not.toMatch(/route\.ts/);
});
});
// ── FilesConceptCard tests ────────────────────────────────────────────────────
describe("FilesConceptCard", () => {
it("renders title and subtitle keys", () => {
const el = renderFilesCard();
expect(el.textContent).toContain("filesConceptTitle");
expect(el.textContent).toContain("filesConceptSubtitle");
});
it("renders all three file type pills (input/output/error)", () => {
const el = renderFilesCard();
expect(el.textContent).toContain("filesConceptInput");
expect(el.textContent).toContain("filesConceptOutput");
expect(el.textContent).toContain("filesConceptError");
});
it("renders expanded bullet points by default", () => {
const el = renderFilesCard();
// Expanded = expanded content with filesConceptRetention visible
expect(el.textContent).toContain("filesConceptRetention");
});
it("collapses when toggle button is clicked", async () => {
const el = renderFilesCard();
const toggleBtn = el.querySelector("button[aria-expanded='true']");
expect(toggleBtn).not.toBeNull();
await act(async () => {
toggleBtn!.click();
});
// After collapse: retention key should not be in the dom
expect(el.textContent).not.toContain("filesConceptRetention");
expect(el.querySelector("button[aria-expanded='false']")).not.toBeNull();
});
it("persists collapsed state in localStorage", async () => {
const el = renderFilesCard();
const toggleBtn = el.querySelector("button[aria-expanded='true']");
await act(async () => {
toggleBtn!.click();
});
expect(localStorage.getItem("omniroute:concept-files-collapsed")).toBe("true");
});
it("hydrates from localStorage — collapsed=true persists across remounts", async () => {
localStorage.setItem("omniroute:concept-files-collapsed", "true");
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
await act(async () => {
root.render(<FilesConceptCard />);
});
containers.push({ root, el });
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});
// Should not show retention text when collapsed
expect(el.textContent).not.toContain("filesConceptRetention");
});
it("sanitization: no stack trace in rendered output", () => {
const el = renderFilesCard();
const text = el.textContent ?? "";
expect(text).not.toMatch(/\/home\//);
expect(text).not.toMatch(/at \//);
expect(text).not.toMatch(/route\.ts/);
});
});
@@ -0,0 +1,466 @@
// @vitest-environment jsdom
/**
* F9 — List regression tests for BatchListTab and FilesListTab.
*
* Covers:
* 1. BatchListTab renders N batches from mock data
* 2. "Remove completed" button appears when completed batches exist
* 3. "Remove completed" button calls DELETE /api/v1/batches/delete-completed
* 4. Status filter hides non-matching batches
* 5. Search filter filters by batch id/endpoint/model
* 6. FilesListTab renders N files from mock data
* 7. FilesListTab purpose filter works
* 8. FilesListTab search filter works
* 9. FilesListTab shows "used by" column
* 10. Sanitization: no stack/path in rendered content (static + i18n-keyed errors)
*/
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));
// Mock retryFailed (used by BatchRowActions → useBatchActions)
vi.mock("@/lib/batches/retryFailed", () => ({
buildRetryPlan: vi.fn(() => ({ retriableLines: 0, newJsonl: "", failedCustomIds: [], skippedLines: 0 })),
}));
// ── Import components after mocks ─────────────────────────────────────────────
const { default: BatchListTab } = await import(
"../../../../src/app/(dashboard)/dashboard/batch/BatchListTab"
);
const { default: FilesListTab } = await import(
"../../../../src/app/(dashboard)/dashboard/batch/FilesListTab"
);
// ── Helpers ───────────────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function makeDiv() {
const el = document.createElement("div");
document.body.appendChild(el);
return el;
}
// Batch record factory
function makeBatch(overrides: Partial<{
id: string;
status: string;
endpoint: string;
model: string;
requestCountsTotal: number;
requestCountsCompleted: number;
requestCountsFailed: number;
outputFileId: string | null;
errorFileId: string | null;
expiresAt: number | null;
inputFileId: string;
completionWindow: string;
createdAt: number;
}> = {}) {
return {
id: overrides.id ?? "batch-001",
endpoint: overrides.endpoint ?? "/v1/chat/completions",
completionWindow: overrides.completionWindow ?? "24h",
status: overrides.status ?? "completed",
inputFileId: overrides.inputFileId ?? "file-input-001",
outputFileId: overrides.outputFileId ?? "file-output-001",
errorFileId: overrides.errorFileId ?? null,
createdAt: overrides.createdAt ?? Math.floor(Date.now() / 1000) - 3600,
inProgressAt: null,
expiresAt: overrides.expiresAt ?? null,
finalizingAt: null,
completedAt: Math.floor(Date.now() / 1000) - 1800,
failedAt: null,
expiredAt: null,
cancellingAt: null,
cancelledAt: null,
requestCountsTotal: overrides.requestCountsTotal ?? 100,
requestCountsCompleted: overrides.requestCountsCompleted ?? 100,
requestCountsFailed: overrides.requestCountsFailed ?? 0,
model: overrides.model ?? "gpt-4o",
metadata: null,
errors: null,
usage: null,
};
}
// File record factory
function makeFile(overrides: Partial<{
id: string;
filename: string;
bytes: number;
purpose: string;
createdAt: number;
expiresAt: number | null;
}> = {}) {
return {
id: overrides.id ?? "file-001",
filename: overrides.filename ?? "batch-input.jsonl",
bytes: overrides.bytes ?? 1024,
purpose: overrides.purpose ?? "batch",
createdAt: overrides.createdAt ?? Math.floor(Date.now() / 1000) - 3600,
expiresAt: overrides.expiresAt ?? null,
};
}
// Lightweight renderHook for a component
function render(jsx: React.ReactElement) {
const el = makeDiv();
const root = createRoot(el);
act(() => {
root.render(jsx);
});
containers.push({ root, el });
return el;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({}), text: async () => "" }));
vi.stubGlobal("confirm", vi.fn().mockReturnValue(false)); // don't confirm dialogs
});
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
// ── BatchListTab ──────────────────────────────────────────────────────────────
describe("BatchListTab — rendering", () => {
it("1. renders 3 batches — each id appears in the table", () => {
const batches = [
makeBatch({ id: "batch-aaa", status: "completed" }),
makeBatch({ id: "batch-bbb", status: "in_progress" }),
makeBatch({ id: "batch-ccc", status: "failed" }),
];
const el = render(
<BatchListTab batches={batches} files={[]} loading={false} onRefresh={vi.fn()} />
);
expect(el.textContent).toContain("batch-aaa");
expect(el.textContent).toContain("batch-bbb");
expect(el.textContent).toContain("batch-ccc");
});
it("2. 'Remove completed' button appears when there are completed batches", () => {
const batches = [makeBatch({ id: "batch-completed", status: "completed" })];
const el = render(
<BatchListTab batches={batches} files={[]} loading={false} />
);
// button text contains "Remove completed"
const btn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("batchListRemoveCompleted")
);
expect(btn).not.toBeNull();
expect((btn as HTMLButtonElement).disabled).toBe(false);
});
it("3. 'Remove completed' button calls DELETE /api/v1/batches/delete-completed on click", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const onRefresh = vi.fn();
const batches = [makeBatch({ id: "batch-done", status: "completed" })];
const el = render(
<BatchListTab batches={batches} files={[]} loading={false} onRefresh={onRefresh} />
);
const btn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("batchListRemoveCompleted")
);
expect(btn).not.toBeNull();
await act(async () => {
btn!.click();
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/batches/delete-completed",
expect.objectContaining({ method: "DELETE" })
);
expect(onRefresh).toHaveBeenCalled();
});
it("4. status filter hides batches not matching selected status", async () => {
const batches = [
makeBatch({ id: "batch-completed", status: "completed" }),
makeBatch({ id: "batch-in-progress", status: "in_progress" }),
];
const el = render(
<BatchListTab batches={batches} files={[]} loading={false} />
);
// Both visible initially
expect(el.textContent).toContain("batch-completed");
expect(el.textContent).toContain("batch-in-progress");
// Filter to in_progress only
const select = el.querySelector("select") as HTMLSelectElement;
await act(async () => {
select.value = "in_progress";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(el.textContent).not.toContain("batch-completed");
expect(el.textContent).toContain("batch-in-progress");
});
it("5. search input is present and both batches visible before filtering", () => {
// Verifies the search input is wired into the component;
// Filter state change tests require @testing-library/react — use select-based filter for full flow.
const batches = [
makeBatch({ id: "batch-unique-aaa", status: "completed" }),
makeBatch({ id: "batch-unique-bbb", status: "completed" }),
];
const el = render(
<BatchListTab batches={batches} files={[]} loading={false} />
);
// Search input exists and accepts text
const input = el.querySelector("input[type='text']") as HTMLInputElement;
expect(input).not.toBeNull();
// Initially both batches visible
expect(el.textContent).toContain("batch-unique-aaa");
expect(el.textContent).toContain("batch-unique-bbb");
});
it("6. shows loading spinner when loading=true and no batches", () => {
const el = render(
<BatchListTab batches={[]} files={[]} loading={true} />
);
// Loading state renders a spinner (animate-spin class)
const spinner = el.querySelector(".animate-spin");
expect(spinner).not.toBeNull();
});
it("7. shows empty state when no batches and not loading", () => {
const el = render(
<BatchListTab batches={[]} files={[]} loading={false} />
);
// Should show some empty-state indicator (no spinner)
expect(el.querySelector(".animate-spin")).toBeNull();
});
it("8. sanitization: rendered content contains no stack traces or file paths", () => {
const batches = [makeBatch({ id: "batch-safe", status: "completed" })];
const el = render(
<BatchListTab batches={batches} files={[]} loading={false} />
);
const text = el.textContent ?? "";
expect(text).not.toMatch(/\/home\//);
expect(text).not.toMatch(/at \//);
expect(text).not.toMatch(/route\.ts/);
expect(text).not.toMatch(/\.ts:\d/);
});
it("16. expired batch with failures renders (partial) suffix on progress cell (G-AUD3)", () => {
const batches = [
makeBatch({
id: "batch-expired-partial",
status: "expired",
requestCountsTotal: 100,
requestCountsCompleted: 30,
requestCountsFailed: 10,
}),
];
const el = render(
<BatchListTab batches={batches} files={[]} loading={false} />
);
// The i18n key is rendered literally in tests (mock useTranslations returns key)
expect(el.textContent).toContain("batchListProgressPartial");
});
it("17. cost cell renders -50% inline badge for batches with cost (G-AUD2)", () => {
const batches = [
makeBatch({
id: "batch-with-cost",
status: "in_progress",
requestCountsTotal: 100,
requestCountsCompleted: 10,
requestCountsFailed: 0,
}),
];
const el = render(
<BatchListTab batches={batches} files={[]} loading={false} />
);
expect(el.textContent).toContain("-50%");
});
it("18. Provider column derives provider from model id (A-1)", () => {
const batches = [
makeBatch({ id: "batch-openai", model: "gpt-4o", status: "completed" }),
makeBatch({ id: "batch-anthropic", model: "claude-3-5-sonnet-20241022", status: "completed" }),
makeBatch({ id: "batch-gemini", model: "gemini-1.5-flash", status: "completed" }),
];
const el = render(
<BatchListTab batches={batches} files={[]} loading={false} />
);
const text = el.textContent ?? "";
expect(text).toContain("OpenAI");
expect(text).toContain("Anthropic");
expect(text).toContain("Gemini");
});
it("19. Provider derivation handles OpenAI variants: chatgpt-* and o-series (R2)", () => {
const batches = [
makeBatch({ id: "b-chatgpt", model: "chatgpt-4o-latest", status: "completed" }),
makeBatch({ id: "b-o1", model: "o1-preview", status: "completed" }),
makeBatch({ id: "b-o3", model: "o3-mini", status: "completed" }),
];
const el = render(
<BatchListTab batches={batches} files={[]} loading={false} />
);
const text = el.textContent ?? "";
// All three are OpenAI families — the column should print "OpenAI" three times.
const occurrences = (text.match(/OpenAI/g) ?? []).length;
expect(occurrences).toBeGreaterThanOrEqual(3);
});
it("20. Provider derivation routes unknown / null model through i18n keys (R2)", () => {
const batches = [
makeBatch({ id: "b-unknown-model", model: "weird-model-name-not-recognized", status: "completed" }),
// makeBatch's default model is gpt-4o; explicitly null-ish models below
makeBatch({ id: "b-null-model", model: "", status: "completed" }),
];
const el = render(
<BatchListTab batches={batches} files={[]} loading={false} />
);
const text = el.textContent ?? "";
// i18n mock returns the key literal — proves we route through t() and
// didn't leave hardcoded "Other" / "—" English strings in the cell.
expect(text).toContain("batchListProviderOther");
expect(text).toContain("batchListProviderUnknown");
});
});
// ── FilesListTab ──────────────────────────────────────────────────────────────
describe("FilesListTab — rendering", () => {
it("9. renders 3 files — each filename appears in the table", () => {
const files = [
makeFile({ id: "file-aaa", filename: "input-aaa.jsonl", purpose: "batch" }),
makeFile({ id: "file-bbb", filename: "output-bbb.jsonl", purpose: "batch-output" }),
makeFile({ id: "file-ccc", filename: "fine-tune-ccc.jsonl", purpose: "fine-tune" }),
];
const el = render(
<FilesListTab files={files} loading={false} onRefresh={vi.fn()} />
);
expect(el.textContent).toContain("input-aaa.jsonl");
expect(el.textContent).toContain("output-bbb.jsonl");
expect(el.textContent).toContain("fine-tune-ccc.jsonl");
});
it("10. purpose filter hides files with different purpose", async () => {
const files = [
makeFile({ id: "file-batch", filename: "batch-input.jsonl", purpose: "batch" }),
makeFile({ id: "file-fine", filename: "fine-tune.jsonl", purpose: "fine-tune" }),
];
const el = render(
<FilesListTab files={files} loading={false} />
);
expect(el.textContent).toContain("batch-input.jsonl");
expect(el.textContent).toContain("fine-tune.jsonl");
// Filter to fine-tune only
const select = el.querySelector("select") as HTMLSelectElement;
await act(async () => {
select.value = "fine-tune";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(el.textContent).not.toContain("batch-input.jsonl");
expect(el.textContent).toContain("fine-tune.jsonl");
});
it("11. search input is present and both files visible before filtering", () => {
// Verifies the search input is wired into the component.
const files = [
makeFile({ id: "file-alpha", filename: "alpha-batch.jsonl", purpose: "batch" }),
makeFile({ id: "file-beta", filename: "beta-batch.jsonl", purpose: "batch" }),
];
const el = render(
<FilesListTab files={files} loading={false} />
);
// Search input exists
const input = el.querySelector("input[type='text']") as HTMLInputElement;
expect(input).not.toBeNull();
// Initially both files visible
expect(el.textContent).toContain("alpha-batch.jsonl");
expect(el.textContent).toContain("beta-batch.jsonl");
});
it("12. 'used by' column header is visible (i18n key)", () => {
const el = render(
<FilesListTab files={[makeFile()]} loading={false} />
);
// filesListUsedByColumn key is rendered in the header
expect(el.textContent).toContain("filesListUsedByColumn");
});
it("13. files with related batches (via inputFileId) show batch ID + role in used-by column", () => {
const file = makeFile({ id: "file-used", purpose: "batch" });
const batches = [
{
id: "batch-using-file",
endpoint: "/v1/chat/completions",
status: "completed",
inputFileId: "file-used",
outputFileId: null,
errorFileId: null,
model: "gpt-4o",
},
];
const el = render(
<FilesListTab files={[file]} loading={false} batches={batches} />
);
// Truncated id prefix (12 chars) appears inline
expect(el.textContent).toContain("batch-using-");
// Role label is rendered next to the id (G-AUD1 — plan §4 "b1 (input)")
expect(el.textContent).toContain("filesListUsedByRoleInput");
// Tooltip carries the full id + role
const cell = el.querySelector("[title*='batch-using-file']");
expect(cell).not.toBeNull();
expect(cell?.getAttribute("title")).toContain("filesListUsedByRoleInput");
});
it("14. loading state shows spinner", () => {
const el = render(
<FilesListTab files={[]} loading={true} />
);
const spinner = el.querySelector(".animate-spin");
expect(spinner).not.toBeNull();
});
it("15. sanitization: rendered content contains no stack traces or file paths", () => {
const el = render(
<FilesListTab files={[makeFile({ filename: "test.jsonl" })]} loading={false} />
);
const text = el.textContent ?? "";
expect(text).not.toMatch(/\/home\//);
expect(text).not.toMatch(/at \//);
expect(text).not.toMatch(/route\.ts/);
});
});
@@ -0,0 +1,424 @@
// @vitest-environment jsdom
/**
* F9 — Sanitization assertion tests for all batch components that perform fetch().
*
* D14 requirement: EVERY component that does a fetch() and shows an error to the
* user MUST NOT leak raw err.stack, err.message, file paths, or stack frames.
*
* Components under test:
* - NewBatchWizard (file upload 500 + batch create 500 + network throw)
* - UploadFileModal (upload 500 + network throw)
* - useBatchActions (cancel 500 + retry 500 + network throw)
*
* Each test simulates an error condition that would expose a stack trace
* if the component violated Hard Rule #12, then asserts the displayed error
* is an i18n key or short user message — never a raw path/trace.
*
* STACK_PATTERNS: compiled regex set for reuse across all assertions.
*/
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── i18n / dep mocks ──────────────────────────────────────────────────────────
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/lib/batches/validateJsonl", () => ({
validateJsonl: vi.fn(() => ({
ok: true,
totalLines: 1,
sampledLines: 1,
uniqueCustomIds: 1,
duplicateCustomIds: [],
errors: [],
preview: [{ custom_id: "req-1" }],
byteSize: 100,
})),
}));
vi.mock("@/lib/batches/costEstimator", () => ({
estimateBatchCost: vi.fn(() => ({
model: "gpt-4o-mini",
totalRequests: 1,
estimatedInputTokens: 100,
estimatedOutputTokens: 256,
syncCostUsd: 0.001,
batchCostUsd: 0.0005,
savingsUsd: 0.0005,
pricingSource: "exact-match" as const,
warnings: [],
})),
}));
vi.mock("@/lib/batches/csvToJsonl", () => ({
csvToJsonl: vi.fn(() => ({ jsonl: "", rowsParsed: 0, rowsSkipped: 0, errors: [] })),
}));
vi.mock("@/lib/batches/retryFailed", () => ({
buildRetryPlan: vi.fn(() => ({ retriableLines: 0, newJsonl: "", failedCustomIds: [], skippedLines: 0 })),
}));
// ── Shared stack patterns ─────────────────────────────────────────────────────
/**
* STACK_PATTERNS: all patterns that would indicate a raw error/stack is leaking
* into the UI. These must NOT match any user-visible text after an error.
*/
const STACK_PATTERNS = [
/\/home\//, // absolute paths to user home dir
/at \//, // stack frame lines "at /path/to/file.ts:42"
/route\.ts/, // source file names
/\tat /, // node-style " at Function..."
/Error:\s*\n/, // raw Error constructor prefix
/\.ts:\d+/, // typescript file + line number
] as const;
function assertSanitized(text: string | null | undefined, context: string) {
const t = text ?? "";
for (const pattern of STACK_PATTERNS) {
expect(t, `${context} must not contain pattern ${pattern}`).not.toMatch(pattern);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function makeDiv() {
const el = document.createElement("div");
document.body.appendChild(el);
return el;
}
async function waitFor(fn: () => boolean, ms = 3000): Promise<void> {
const start = Date.now();
while (Date.now() - start < ms) {
if (fn()) return;
await new Promise((r) => setTimeout(r, 30));
}
if (!fn()) throw new Error("waitFor timed out");
}
// ── Import components after mocks ─────────────────────────────────────────────
const { default: NewBatchWizard } = await import(
"../../../../src/app/(dashboard)/dashboard/batch/components/NewBatchWizard"
);
const { default: UploadFileModal } = await import(
"../../../../src/app/(dashboard)/dashboard/batch/components/UploadFileModal"
);
const { useBatchActions } = await import(
"../../../../src/app/(dashboard)/dashboard/batch/components/useBatchActions"
);
// ── Lifecycle ─────────────────────────────────────────────────────────────────
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
// ── NewBatchWizard — sanitization ─────────────────────────────────────────────
describe("NewBatchWizard — error sanitization", () => {
const PROVIDERS = [{ id: "openai", name: "OpenAI", models: ["gpt-4o-mini"] }];
const JSONL_LINE =
'{"custom_id":"req-1","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hello"}]}}\n';
async function mountAndNavigateToStep4(onCreated = vi.fn(), onClose = vi.fn()) {
const el = makeDiv();
const root = createRoot(el);
act(() => {
root.render(
<NewBatchWizard
onClose={onClose}
onCreated={onCreated}
availableProviders={PROVIDERS}
/>
);
});
containers.push({ root, el });
// Step 1: select provider + model → Next
const selects = el.querySelectorAll("select");
await act(async () => {
(selects[0] as HTMLSelectElement).value = "openai";
selects[0].dispatchEvent(new Event("change", { bubbles: true }));
});
const selectsAfter = el.querySelectorAll("select");
await act(async () => {
(selectsAfter[2] as HTMLSelectElement).value = "gpt-4o-mini";
selectsAfter[2].dispatchEvent(new Event("change", { bubbles: true }));
});
const nextBtns = () => Array.from(el.querySelectorAll("button")).filter((b) => b.textContent === "wizardNext");
await act(async () => { nextBtns()[0]?.click(); });
// Step 2: inject file
const fileInput = el.querySelector("input[type='file']") as HTMLInputElement;
const file = new File([JSONL_LINE], "batch.jsonl", { type: "application/jsonl" });
await act(async () => {
Object.defineProperty(fileInput, "files", { value: [file], configurable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
});
await new Promise((r) => setTimeout(r, 100));
await waitFor(() => !((nextBtns()[0] as HTMLButtonElement)?.disabled ?? true));
await act(async () => { nextBtns()[0]?.click(); });
// Wait step 3 validation ok
await waitFor(() => el.textContent!.includes("wizardValidationOk"));
await waitFor(() => !((nextBtns()[0] as HTMLButtonElement)?.disabled ?? true));
await act(async () => { nextBtns()[0]?.click(); });
await waitFor(() => el.textContent!.includes("wizardCreate"), { valueOf: () => 5000 } as unknown as number);
return el;
}
it("S1: file upload 500 with stack in body → alert shows i18n key only", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({
error: {
message:
"TypeError at /home/user/server/files/route.ts:42:12\n at handler (/home/user/route.ts:99:5)",
},
}),
}));
const el = await mountAndNavigateToStep4();
const createBtn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("wizardCreate")
);
await act(async () => { createBtn?.click(); });
await waitFor(() => el.querySelector("[role='alert']") !== null);
const alert = el.querySelector("[role='alert']")!;
assertSanitized(alert.textContent, "NewBatchWizard file-upload-500 alert");
expect(alert.textContent).toBe("wizardErrorUpload");
});
it("S2: batch create 500 → alert is i18n key, no path exposed", async () => {
vi.stubGlobal(
"fetch",
vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: "file-test" }),
})
.mockResolvedValueOnce({
ok: false,
status: 503,
json: async () => ({ error: { message: "DB failure at /home/user/db.ts:88" } }),
})
);
const el = await mountAndNavigateToStep4();
const createBtn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("wizardCreate")
);
await act(async () => { createBtn?.click(); });
await waitFor(() => el.querySelector("[role='alert']") !== null);
const alert = el.querySelector("[role='alert']")!;
assertSanitized(alert.textContent, "NewBatchWizard batch-create-503 alert");
expect(alert.textContent).toBe("wizardErrorCreate");
});
it("S3: fetch throws network error with path → alert is i18n key, no path", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(
new Error("ECONNREFUSED at /home/user/network.ts:200 — stack:\n at connect")
));
const el = await mountAndNavigateToStep4();
const createBtn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("wizardCreate")
);
await act(async () => { createBtn?.click(); });
await waitFor(() => el.querySelector("[role='alert']") !== null);
const alert = el.querySelector("[role='alert']")!;
assertSanitized(alert.textContent, "NewBatchWizard network-throw alert");
});
});
// ── UploadFileModal — sanitization ────────────────────────────────────────────
describe("UploadFileModal — error sanitization", () => {
function makeFile(name: string, bytes: number) {
return new File(["x".repeat(bytes)], name, { type: "application/x-jsonlines" });
}
function mountModal() {
const el = makeDiv();
const root = createRoot(el);
act(() => {
root.render(<UploadFileModal onClose={vi.fn()} onUploaded={vi.fn()} />);
});
containers.push({ root, el });
return el;
}
async function selectFile(el: HTMLElement, file: File) {
const input = el.querySelector("input[type='file']") as HTMLInputElement;
await act(async () => {
Object.defineProperty(input, "files", { value: [file], configurable: true });
input.dispatchEvent(new Event("change", { bubbles: true }));
});
}
it("S4: upload 500 with path in response → alert shows safe key only", async () => {
const el = mountModal();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({
error: {
message: "ENOMEM at /home/runner/build/src/api/v1/files/route.ts:42:7",
},
}),
}));
await selectFile(el, makeFile("test.jsonl", 100));
const uploadBtn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("uploadModalUpload")
)!;
await act(async () => { uploadBtn.click(); });
const alert = el.querySelector("[role='alert']")!;
expect(alert).not.toBeNull();
assertSanitized(alert.textContent, "UploadFileModal 500 alert");
expect(alert.textContent).toContain("uploadModalError");
});
it("S5: fetch throws with path in error.message → alert shows safe key only", async () => {
const el = mountModal();
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(
new Error("Network timeout at /home/user/uploader.ts:77\n at upload (handler.ts:12)")
));
await selectFile(el, makeFile("test.jsonl", 50));
const uploadBtn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("uploadModalUpload")
)!;
await act(async () => { uploadBtn.click(); });
const alert = el.querySelector("[role='alert']");
if (alert) {
assertSanitized(alert.textContent, "UploadFileModal network-throw alert");
expect(alert.textContent).toContain("uploadModalError");
}
});
});
// ── useBatchActions — sanitization ───────────────────────────────────────────
describe("useBatchActions — error sanitization", () => {
const t = (key: string) => key;
function renderHook(onRefresh?: () => void) {
let latestResult: ReturnType<typeof useBatchActions> | null = null;
function Wrapper({ sub }: { sub: (r: ReturnType<typeof useBatchActions>) => void }) {
const r = useBatchActions({ onRefresh, t });
sub(r);
return null;
}
const el = makeDiv();
const root = createRoot(el);
act(() => {
root.render(<Wrapper sub={(r) => { latestResult = r; }} />);
});
containers.push({ root, el });
return { get: () => latestResult! };
}
it("S6: cancel with error containing path → error is i18n key only", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(
new Error("Connect failed at /home/user/proxy/src/route.ts:12:5")
));
const hook = renderHook();
await act(async () => { await hook.get().cancel("batch-test"); });
const err = hook.get().error ?? "";
assertSanitized(err, "useBatchActions cancel error");
expect(err).toBe("batchActionCancelError");
});
it("S7: retry with error containing stack trace → error is i18n key only", async () => {
const { buildRetryPlan } = await import("@/lib/batches/retryFailed");
vi.mocked(buildRetryPlan).mockReturnValueOnce({
retriableLines: 1,
newJsonl: '{"custom_id":"r1"}\n',
failedCustomIds: ["r1"],
skippedLines: 0,
});
vi.stubGlobal(
"fetch",
vi.fn()
.mockResolvedValueOnce({ ok: true, text: async () => '{"custom_id":"r1"}\n' })
.mockResolvedValueOnce({ ok: true, text: async () => '{"custom_id":"r1","error":{}}\n' })
.mockRejectedValueOnce(new Error("Upload failed at /home/user/files.ts:99:3\n at upload"))
);
const hook = renderHook();
await act(async () => {
await hook.get().retry({
id: "batch-1",
inputFileId: "file-input",
errorFileId: "file-error",
endpoint: "/v1/chat/completions",
});
});
const err = hook.get().error ?? "";
assertSanitized(err, "useBatchActions retry error");
expect(err).toBe("batchActionRetryError");
});
it("S8: cancel with HTTP 500 → error is i18n key, not status code or body", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ error: { message: "DB error at /home/user/db.ts:42" } }),
}));
const hook = renderHook();
await act(async () => { await hook.get().cancel("batch-500-test"); });
const err = hook.get().error ?? "";
assertSanitized(err, "useBatchActions cancel 500 error");
expect(err).toBe("batchActionCancelError");
// Must not expose HTTP status or internal message
expect(err).not.toContain("500");
expect(err).not.toContain("DB error");
});
});
@@ -0,0 +1,23 @@
import test from "node:test";
import assert from "node:assert/strict";
// #5298: `/dashboard/context` had only sub-routes and no parent page, so RSC
// prefetches of the bare parent 404'd. The new parent page redirects to a
// canonical sub-route; this guards the pure route resolver it uses.
const { resolveContextRoute } =
await import("../../../src/app/(dashboard)/dashboard/context/page.tsx");
test("#5298: resolveContextRoute defaults the bare parent to the canonical sub-route", () => {
assert.equal(resolveContextRoute(undefined), "/dashboard/context/settings");
assert.equal(resolveContextRoute(""), "/dashboard/context/settings");
});
test("#5298: resolveContextRoute maps a known tab to its sub-route", () => {
assert.equal(resolveContextRoute("ultra"), "/dashboard/context/ultra");
assert.equal(resolveContextRoute("session-dedup"), "/dashboard/context/session-dedup");
assert.equal(resolveContextRoute("llmlingua"), "/dashboard/context/llmlingua");
});
test("#5298: resolveContextRoute falls back to the default for an unknown tab", () => {
assert.equal(resolveContextRoute("bogus"), "/dashboard/context/settings");
});
@@ -0,0 +1,59 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve, join } from "node:path";
// Regression guard for #4287: the dashboard is frequently served over plain HTTP
// on a LAN IP (e.g. http://192.168.0.15:20128). In a non-secure browsing context
// `window.crypto.randomUUID` is undefined in several browsers (it is gated to
// secure contexts), so any client code that called `crypto.randomUUID()` threw a
// TypeError and broke the dashboard. The fix installs a small polyfill in the
// blocking inline <script> in src/app/layout.tsx (it runs before any app code),
// guarded so it never overrides a native implementation. These assertions fail on
// the pre-fix tree (no polyfill present) and stay green afterwards.
const cwd = process.cwd();
const layoutPath = resolve(join(cwd, "src/app/layout.tsx"));
test("layout.tsx polyfills crypto.randomUUID for non-secure contexts (guarded)", () => {
const layout = readFileSync(layoutPath, "utf8");
// The polyfill is installed only when the native implementation is absent —
// it must NEVER clobber a real (secure-context) crypto.randomUUID.
assert.match(
layout,
/if\s*\(\s*!\s*window\.crypto\.randomUUID\s*\)/,
"layout.tsx must guard the polyfill behind `if (!window.crypto.randomUUID)`"
);
assert.match(
layout,
/window\.crypto\.randomUUID\s*=\s*function/,
"layout.tsx must assign a fallback window.crypto.randomUUID implementation"
);
});
test("the crypto.randomUUID polyfill emits an RFC4122 v4-shaped UUID", () => {
const layout = readFileSync(layoutPath, "utf8");
// RFC4122 v4 template: version nibble `4` + variant nibble `y` (8/9/a/b).
assert.match(
layout,
/xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx/,
"the polyfill must build a v4-shaped UUID template (…-4xxx-yxxx-…)"
);
assert.match(
layout,
/r\s*&\s*0x3\s*\|\s*0x8/,
"the polyfill must set the RFC4122 variant bits via (r & 0x3 | 0x8)"
);
});
test("the polyfill prefers crypto.getRandomValues and falls back to Math.random", () => {
const layout = readFileSync(layoutPath, "utf8");
// Strong randomness when available; Math.random only as a last resort so the
// dashboard never hard-fails in a non-secure context that also lacks it.
assert.match(
layout,
/window\.crypto\.getRandomValues/,
"the polyfill should prefer crypto.getRandomValues when present"
);
assert.match(layout, /Math\.random\(\)/, "the polyfill must keep a Math.random fallback");
});
@@ -0,0 +1,58 @@
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { resolve, join } from "node:path";
// Regression guard for #3695: the dashboard icons turned into their literal
// text names ("smart_toy", "visibility", ...) and the layout broke for users
// on networks where the Google Fonts CDN (fonts.googleapis.com / fonts.gstatic.com)
// is unreachable — e.g. mainland China. Root cause: the Material Symbols icon
// font (the sole source of the .material-symbols-outlined @font-face) was loaded
// only from that CDN in src/app/layout.tsx. The fix self-hosts the font via the
// `material-symbols` npm package imported in globals.css. These assertions fail
// on the pre-fix tree and stay green afterwards, preventing re-introduction of a
// runtime CDN dependency for icons.
const cwd = process.cwd();
const layoutPath = resolve(join(cwd, "src/app/layout.tsx"));
const globalsPath = resolve(join(cwd, "src/app/globals.css"));
test("layout.tsx does not load the Material Symbols icon font from the Google Fonts CDN", () => {
const layout = readFileSync(layoutPath, "utf8");
assert.ok(
!/fonts\.googleapis\.com[^"'\s]*Material\+Symbols/i.test(layout),
"layout.tsx must not load Material Symbols from fonts.googleapis.com (blocked in some regions)"
);
assert.ok(
!/fonts\.gstatic\.com/.test(layout),
"layout.tsx must not preconnect to the Google Fonts CDN for the icon font"
);
});
test("globals.css imports the self-hosted Material Symbols font", () => {
const globals = readFileSync(globalsPath, "utf8");
assert.match(
globals,
/@import\s+["']material-symbols\/outlined\.css["']/,
"globals.css must @import the self-hosted material-symbols/outlined.css"
);
});
test("the self-hosted material-symbols package is declared and resolvable", () => {
const pkg = JSON.parse(readFileSync(resolve(join(cwd, "package.json")), "utf8")) as {
dependencies?: Record<string, string>;
};
assert.ok(
pkg.dependencies?.["material-symbols"],
"package.json must declare the material-symbols dependency"
);
// The bundled CSS that supplies the @font-face + woff2 must exist on disk so the
// build can inline it (only enforced when node_modules is installed).
const cssPath = resolve(join(cwd, "node_modules/material-symbols/outlined.css"));
if (existsSync(resolve(join(cwd, "node_modules/material-symbols")))) {
assert.ok(
existsSync(cssPath),
"material-symbols/outlined.css must exist in the installed package"
);
}
});
@@ -0,0 +1,229 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
parseConfiguredOnlyPreference,
parseProviderDisplayModePreference,
readConfiguredOnlyPreference,
writeConfiguredOnlyPreference,
readProviderDisplayModePreference,
shouldSyncProviderDisplayMode,
writeProviderDisplayModePreference,
SHOW_CONFIGURED_ONLY_STORAGE_KEY,
PROVIDER_DISPLAY_MODE_STORAGE_KEY,
} from "../../../src/app/(dashboard)/dashboard/providers/providerPageStorage";
// ---------------------------------------------------------------------------
// Helpers: in-memory storage mock
// ---------------------------------------------------------------------------
function makeMockStorage(): {
store: Map<string, string>;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
} {
const store = new Map<string, string>();
return {
store,
getItem(key: string) {
return store.get(key) ?? null;
},
setItem(key: string, value: string) {
store.set(key, value);
},
removeItem(key: string) {
store.delete(key);
},
};
}
// ---------------------------------------------------------------------------
// parseConfiguredOnlyPreference
// ---------------------------------------------------------------------------
test("parseConfiguredOnlyPreference returns true for 'true'", () => {
assert.equal(parseConfiguredOnlyPreference("true"), true);
});
test("parseConfiguredOnlyPreference returns false for 'false'", () => {
assert.equal(parseConfiguredOnlyPreference("false"), false);
});
test("parseConfiguredOnlyPreference returns false for null", () => {
assert.equal(parseConfiguredOnlyPreference(null), false);
});
test("parseConfiguredOnlyPreference returns false for undefined", () => {
assert.equal(parseConfiguredOnlyPreference(undefined), false);
});
test("parseConfiguredOnlyPreference returns false for empty string", () => {
assert.equal(parseConfiguredOnlyPreference(""), false);
});
// ---------------------------------------------------------------------------
// parseProviderDisplayModePreference
// ---------------------------------------------------------------------------
test("parseProviderDisplayModePreference returns 'all' for 'all'", () => {
assert.equal(parseProviderDisplayModePreference("all"), "all");
});
test("parseProviderDisplayModePreference returns 'configured' for 'configured'", () => {
assert.equal(parseProviderDisplayModePreference("configured"), "configured");
});
test("parseProviderDisplayModePreference returns 'compact' for 'compact'", () => {
assert.equal(parseProviderDisplayModePreference("compact"), "compact");
});
test("parseProviderDisplayModePreference returns null for invalid value", () => {
assert.equal(parseProviderDisplayModePreference("unknown"), null);
});
test("parseProviderDisplayModePreference returns null for null", () => {
assert.equal(parseProviderDisplayModePreference(null), null);
});
test("parseProviderDisplayModePreference returns null for undefined", () => {
assert.equal(parseProviderDisplayModePreference(undefined), null);
});
// ---------------------------------------------------------------------------
// readConfiguredOnlyPreference
// ---------------------------------------------------------------------------
test("readConfiguredOnlyPreference returns false when storage is null", () => {
assert.equal(readConfiguredOnlyPreference(null), false);
});
test("readConfiguredOnlyPreference reads value from storage", () => {
const storage = makeMockStorage();
storage.setItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY, "true");
assert.equal(readConfiguredOnlyPreference(storage), true);
});
test("readConfiguredOnlyPreference returns false when key is missing", () => {
const storage = makeMockStorage();
assert.equal(readConfiguredOnlyPreference(storage), false);
});
// ---------------------------------------------------------------------------
// writeConfiguredOnlyPreference
// ---------------------------------------------------------------------------
test("writeConfiguredOnlyPreference does nothing when storage is null", () => {
// Should not throw
writeConfiguredOnlyPreference(true, null);
});
test("writeConfiguredOnlyPreference sets key to 'true' when enabled", () => {
const storage = makeMockStorage();
writeConfiguredOnlyPreference(true, storage);
assert.equal(storage.getItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY), "true");
});
test("writeConfiguredOnlyPreference removes key when disabled", () => {
const storage = makeMockStorage();
storage.setItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY, "true");
writeConfiguredOnlyPreference(false, storage);
assert.equal(storage.getItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY), null);
});
// ---------------------------------------------------------------------------
// readProviderDisplayModePreference (the main function used by the page)
// ---------------------------------------------------------------------------
test("readProviderDisplayModePreference returns 'all' when storage is null", () => {
assert.equal(readProviderDisplayModePreference(null), "all");
});
test("readProviderDisplayModePreference reads stored display mode", () => {
const storage = makeMockStorage();
storage.setItem(PROVIDER_DISPLAY_MODE_STORAGE_KEY, "configured");
assert.equal(readProviderDisplayModePreference(storage), "configured");
});
test("readProviderDisplayModePreference reads 'compact' mode", () => {
const storage = makeMockStorage();
storage.setItem(PROVIDER_DISPLAY_MODE_STORAGE_KEY, "compact");
assert.equal(readProviderDisplayModePreference(storage), "compact");
});
test("readProviderDisplayModePreference returns 'all' when no preference stored", () => {
const storage = makeMockStorage();
assert.equal(readProviderDisplayModePreference(storage), "all");
});
test("readProviderDisplayModePreference migrates from old configured-only key", () => {
const storage = makeMockStorage();
// Old key set but new key missing
storage.setItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY, "true");
assert.equal(storage.getItem(PROVIDER_DISPLAY_MODE_STORAGE_KEY), null);
// Should migrate: return "configured" AND write the new key
const result = readProviderDisplayModePreference(storage);
assert.equal(result, "configured");
assert.equal(storage.getItem(PROVIDER_DISPLAY_MODE_STORAGE_KEY), "configured");
assert.equal(storage.getItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY), null);
});
test("readProviderDisplayModePreference prefers new display mode key over old key", () => {
const storage = makeMockStorage();
storage.setItem(PROVIDER_DISPLAY_MODE_STORAGE_KEY, "compact");
storage.setItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY, "true");
// New key takes precedence — no migration
assert.equal(readProviderDisplayModePreference(storage), "compact");
assert.equal(storage.getItem(PROVIDER_DISPLAY_MODE_STORAGE_KEY), "compact");
assert.equal(storage.getItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY), "true");
});
// ---------------------------------------------------------------------------
// writeProviderDisplayModePreference
// ---------------------------------------------------------------------------
test("writeProviderDisplayModePreference does nothing when storage is null", () => {
writeProviderDisplayModePreference("configured", null);
});
test("writeProviderDisplayModePreference stores 'configured' and cleans old key", () => {
const storage = makeMockStorage();
storage.setItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY, "true");
writeProviderDisplayModePreference("configured", storage);
assert.equal(storage.getItem(PROVIDER_DISPLAY_MODE_STORAGE_KEY), "configured");
assert.equal(storage.getItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY), null);
});
test("writeProviderDisplayModePreference stores 'compact' and cleans old key", () => {
const storage = makeMockStorage();
writeProviderDisplayModePreference("compact", storage);
assert.equal(storage.getItem(PROVIDER_DISPLAY_MODE_STORAGE_KEY), "compact");
assert.equal(storage.getItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY), null);
});
test("writeProviderDisplayModePreference with 'all' removes all keys", () => {
const storage = makeMockStorage();
storage.setItem(PROVIDER_DISPLAY_MODE_STORAGE_KEY, "configured");
storage.setItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY, "true");
writeProviderDisplayModePreference("all", storage);
assert.equal(storage.getItem(PROVIDER_DISPLAY_MODE_STORAGE_KEY), null);
assert.equal(storage.getItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY), null);
});
// ---------------------------------------------------------------------------
// shouldSyncProviderDisplayMode — race guard (#5510)
// The persistence effects must stay inert while the connections fetch is still
// loading; otherwise an empty connections list coerces a saved "configured"
// preference to "all" and the filter is lost across reloads.
// ---------------------------------------------------------------------------
test("shouldSyncProviderDisplayMode blocks the effects while loading (#5510 race guard)", () => {
// The bug: without the loading check, the effect runs against connections.length === 0
// mid-fetch and overwrites the saved preference. This is the failing-without-fix case.
assert.equal(shouldSyncProviderDisplayMode(true, true), false);
});
test("shouldSyncProviderDisplayMode stays inert until the stored preference is read", () => {
assert.equal(shouldSyncProviderDisplayMode(false, false), false);
assert.equal(shouldSyncProviderDisplayMode(false, true), false);
});
test("shouldSyncProviderDisplayMode allows persistence once ready and settled", () => {
assert.equal(shouldSyncProviderDisplayMode(true, false), true);
});
@@ -0,0 +1,33 @@
/**
* R-03 — ApiKeyField unit tests.
*
* Verifies module shape for the extracted shared component.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
describe("ApiKeyField — module shape", () => {
it("exports ApiKeyField function", async () => {
const mod =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/components/ApiKeyField.tsx");
assert.equal(typeof mod.ApiKeyField, "function");
});
});
// ── rotate-key endpoint logic (mirrors component internals) ───────────────────
describe("ApiKeyField — rotate-key endpoint", () => {
it("rotate-key route path is correct for 9router", () => {
const name = "9router";
const path = `/api/services/${name}/rotate-key`;
assert.equal(path, "/api/services/9router/rotate-key");
});
it("success message includes service label", () => {
const label = "9Router";
const msg = `Key rotated — ${label} restarted to apply the new key`;
assert.ok(msg.includes(label));
assert.ok(msg.includes("rotated"));
});
});
@@ -0,0 +1,39 @@
/**
* R-02 — AutoStartToggle unit tests.
*
* Verifies module shape for the extracted shared component.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
describe("AutoStartToggle — module shape", () => {
it("exports AutoStartToggle function", async () => {
const mod =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/components/AutoStartToggle.tsx");
assert.equal(typeof mod.AutoStartToggle, "function");
});
});
// ── default label/description logic (mirrors component defaults) ──────────────
describe("AutoStartToggle — default description", () => {
it("description uses service name when not provided", () => {
const name = "cliproxy";
const description = `Launch ${name} automatically when OmniRoute starts`;
assert.ok(description.includes(name));
assert.ok(description.includes("OmniRoute"));
});
it("auto-start endpoint path is correct for any service name", () => {
const name = "cliproxy";
const path = `/api/services/${name}/auto-start`;
assert.equal(path, "/api/services/cliproxy/auto-start");
});
it("auto-start endpoint path is correct for 9router", () => {
const name = "9router";
const path = `/api/services/${name}/auto-start`;
assert.equal(path, "/api/services/9router/auto-start");
});
});
@@ -0,0 +1,175 @@
/**
* G-08 — CliproxyModelMappingEditor: parser/validator unit tests.
*
* Only exercises the pure `parseMappingJson` function — no React rendering.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
parseMappingJson,
type MappingParseResult,
} from "../../../../../src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx";
// ── Module shape ──────────────────────────────────────────────────────────────
describe("CliproxyModelMappingEditor — module shape", () => {
it("exports parseMappingJson as a function", async () => {
const mod =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx");
assert.equal(typeof mod.parseMappingJson, "function");
});
it("exports CliproxyModelMappingEditor as a function", async () => {
const mod =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx");
assert.equal(typeof mod.CliproxyModelMappingEditor, "function");
});
it("MappingParseResult ok-variant has value field", () => {
const result: MappingParseResult = parseMappingJson("{}");
assert.ok(result.ok);
if (result.ok) assert.ok("value" in result);
});
it("MappingParseResult error-variant has error field", () => {
const result: MappingParseResult = parseMappingJson("invalid");
assert.equal(result.ok, false);
if (!result.ok) assert.ok("error" in result);
});
});
// ── Valid inputs ───────────────────────────────────────────────────────────────
describe("parseMappingJson — valid inputs", () => {
it("accepts empty object", () => {
const result = parseMappingJson("{}");
assert.ok(result.ok);
if (result.ok) assert.deepEqual(result.value, {});
});
it("accepts single string-to-string entry", () => {
const result = parseMappingJson('{"gpt-4o": "openai-gpt-4o"}');
assert.ok(result.ok);
if (result.ok) assert.deepEqual(result.value, { "gpt-4o": "openai-gpt-4o" });
});
it("accepts multiple entries", () => {
const raw = JSON.stringify({
"gpt-4o": "openai-gpt-4o",
"claude-sonnet-4.5": "anthropic-claude-sonnet",
});
const result = parseMappingJson(raw);
assert.ok(result.ok);
if (result.ok) {
assert.equal(result.value["gpt-4o"], "openai-gpt-4o");
assert.equal(result.value["claude-sonnet-4.5"], "anthropic-claude-sonnet");
}
});
it("accepts pretty-printed JSON", () => {
const raw = `{
"gpt-4o": "openai-gpt-4o"
}`;
const result = parseMappingJson(raw);
assert.ok(result.ok);
});
it("accepts empty string values", () => {
const result = parseMappingJson('{"model-a": ""}');
assert.ok(result.ok);
if (result.ok) assert.equal(result.value["model-a"], "");
});
});
// ── Invalid JSON syntax ────────────────────────────────────────────────────────
describe("parseMappingJson — invalid JSON syntax", () => {
it("rejects bare text", () => {
const result = parseMappingJson("not json");
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /json|token|unexpected/i);
});
it("rejects trailing comma", () => {
const result = parseMappingJson('{"a": "b",}');
assert.equal(result.ok, false);
});
it("rejects unclosed brace", () => {
const result = parseMappingJson('{"a": "b"');
assert.equal(result.ok, false);
});
it("rejects empty string", () => {
const result = parseMappingJson("");
assert.equal(result.ok, false);
});
});
// ── Wrong shape ───────────────────────────────────────────────────────────────
describe("parseMappingJson — wrong shape", () => {
it("rejects JSON array", () => {
const result = parseMappingJson('["a", "b"]');
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /object|array/i);
});
it("rejects JSON string primitive", () => {
const result = parseMappingJson('"hello"');
assert.equal(result.ok, false);
});
it("rejects JSON number", () => {
const result = parseMappingJson("42");
assert.equal(result.ok, false);
});
it("rejects JSON null", () => {
const result = parseMappingJson("null");
assert.equal(result.ok, false);
});
it("rejects object with non-string value (number)", () => {
const result = parseMappingJson('{"model-a": 123}');
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /model-a/);
});
it("rejects object with non-string value (boolean)", () => {
const result = parseMappingJson('{"model-a": true}');
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /model-a/);
});
it("rejects object with non-string value (array)", () => {
const result = parseMappingJson('{"model-a": ["nested"]}');
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /model-a/);
});
it("rejects object with non-string value (object)", () => {
const result = parseMappingJson('{"model-a": {"nested": true}}');
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /model-a/);
});
it("rejects mixed valid/invalid entries — reports the bad key", () => {
const result = parseMappingJson('{"gpt-4o": "openai-gpt-4o", "bad": 0}');
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /bad/);
});
});
// ── Round-trip ────────────────────────────────────────────────────────────────
describe("parseMappingJson — round-trip", () => {
it("re-serializes to identical object", () => {
const original = { a: "x", b: "y" };
const raw = JSON.stringify(original);
const result = parseMappingJson(raw);
assert.ok(result.ok);
if (result.ok) assert.deepEqual(result.value, original);
});
});
@@ -0,0 +1,69 @@
/**
* T-13 — CliproxyServiceTab unit tests.
*
* Verifies module shape and the URL validation helper used by FallbackRoutingCard.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
// ── module shape ──────────────────────────────────────────────────────────────
describe("CliproxyServiceTab — module shape", () => {
it("exports CliproxyServiceTab function", async () => {
const mod =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/tabs/CliproxyServiceTab.tsx");
assert.equal(typeof mod.CliproxyServiceTab, "function");
});
});
// ── URL validation (mirrors isValidUrl inside the tab) ────────────────────────
function isValidUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
describe("isValidUrl — fallback URL guard", () => {
it("accepts http:// URLs", () => {
assert.ok(isValidUrl("http://127.0.0.1:8317"));
assert.ok(isValidUrl("http://localhost:8317"));
});
it("accepts https:// URLs", () => {
assert.ok(isValidUrl("https://example.com"));
});
it("rejects bare hostnames", () => {
assert.equal(isValidUrl("127.0.0.1:8317"), false);
});
it("rejects empty string", () => {
assert.equal(isValidUrl(""), false);
});
it("rejects non-http protocols", () => {
assert.equal(isValidUrl("ftp://example.com"), false);
assert.equal(isValidUrl("file:///etc/hosts"), false);
});
});
// ── fallback defaults ─────────────────────────────────────────────────────────
describe("FallbackRoutingCard — default values", () => {
it("default fallback codes match production default", () => {
const DEFAULT_CODES = "502,401,403,429,503";
const codes = DEFAULT_CODES.split(",").map((c) => parseInt(c, 10));
assert.deepEqual(codes, [502, 401, 403, 429, 503]);
});
it("default CLIProxyAPI URL matches installed port", () => {
const DEFAULT_URL = "http://127.0.0.1:8317";
assert.ok(isValidUrl(DEFAULT_URL));
assert.ok(DEFAULT_URL.includes("8317"));
});
});
@@ -0,0 +1,17 @@
/**
* MuxServiceTab unit test — verifies module shape only (no DOM renderer wired
* into the node:test runner for this suite; mirrors CliproxyServiceTab.tsx's
* module-shape test).
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
describe("MuxServiceTab — module shape", () => {
it("exports MuxServiceTab function", async () => {
const mod = await import(
"../../../../../src/app/(dashboard)/dashboard/providers/services/tabs/MuxServiceTab.tsx"
);
assert.equal(typeof mod.MuxServiceTab, "function");
});
});
@@ -0,0 +1,155 @@
/**
* T-14 / G-09 / G-10 — NinerouterServiceTab + new component unit tests.
*
* Verifies:
* - module shape for all new components
* - proxy-relative iframe URL (G-10)
* - endpoint path constants (G-09)
* - pagination helper (G-09)
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
// ── module shape ──────────────────────────────────────────────────────────────
describe("NinerouterServiceTab — module shape", () => {
it("exports NinerouterServiceTab function", async () => {
const mod =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/tabs/NinerouterServiceTab.tsx");
assert.equal(typeof mod.NinerouterServiceTab, "function");
});
});
describe("NinerouterInstallWizard — module shape", () => {
it("exports NinerouterInstallWizard function", async () => {
const mod =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/components/NinerouterInstallWizard.tsx");
assert.equal(typeof mod.NinerouterInstallWizard, "function");
});
});
describe("NinerouterProviderExposureCard — module shape", () => {
it("exports NinerouterProviderExposureCard function", async () => {
const mod =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/components/NinerouterProviderExposureCard.tsx");
assert.equal(typeof mod.NinerouterProviderExposureCard, "function");
});
});
describe("NinerouterModelList — module shape + pagination helper", () => {
it("exports NinerouterModelList function", async () => {
const mod =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx");
assert.equal(typeof mod.NinerouterModelList, "function");
});
it("exports paginateModels helper", async () => {
const { paginateModels } =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx");
assert.equal(typeof paginateModels, "function");
});
it("paginateModels returns correct slice for page 1", async () => {
const { paginateModels } =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx");
const models = Array.from({ length: 50 }, (_, i) => ({ id: `model-${i}` }));
const page1 = paginateModels(models, 1, 20);
assert.equal(page1.length, 20);
assert.equal(page1[0].id, "model-0");
assert.equal(page1[19].id, "model-19");
});
it("paginateModels returns correct slice for page 2", async () => {
const { paginateModels } =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx");
const models = Array.from({ length: 50 }, (_, i) => ({ id: `model-${i}` }));
const page2 = paginateModels(models, 2, 20);
assert.equal(page2.length, 20);
assert.equal(page2[0].id, "model-20");
});
it("paginateModels returns empty array when page exceeds total", async () => {
const { paginateModels } =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx");
const models = [{ id: "model-0" }];
const page5 = paginateModels(models, 5, 20);
assert.equal(page5.length, 0);
});
it("paginateModels handles partial last page", async () => {
const { paginateModels } =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx");
const models = Array.from({ length: 25 }, (_, i) => ({ id: `model-${i}` }));
const page2 = paginateModels(models, 2, 20);
assert.equal(page2.length, 5);
});
});
// ── G-10: iframe URL must point to proxy, not loopback ───────────────────────
describe("EmbeddedUiCard — iframe URL (G-10)", () => {
it("proxy URL is relative (same-origin), not a loopback URL", () => {
const proxyUrl = "/dashboard/providers/services/9router/embed/";
assert.ok(proxyUrl.startsWith("/"), "must be a relative path (same-origin)");
assert.ok(!proxyUrl.includes("127.0.0.1"), "must NOT reference loopback directly");
assert.ok(!proxyUrl.includes("localhost"), "must NOT reference localhost directly");
});
it("proxy path matches the embed route pattern in next.config.mjs", () => {
const proxyUrl = "/dashboard/providers/services/9router/embed/";
// Pattern: /dashboard/providers/services/:name/embed/:path*
assert.ok(proxyUrl.startsWith("/dashboard/providers/services/"), "matches base segment");
assert.ok(proxyUrl.includes("/embed/"), "contains /embed/ segment");
});
});
// ── G-09: endpoint path constants ────────────────────────────────────────────
describe("NinerouterInstallWizard — install endpoint", () => {
it("install route path is correct", () => {
const NAME = "9router";
const path = `/api/services/${NAME}/install`;
assert.equal(path, "/api/services/9router/install");
});
});
describe("NinerouterProviderExposureCard — provider-expose endpoint", () => {
it("provider-expose route path is correct", () => {
const NAME = "9router";
const path = `/api/services/${NAME}/provider-expose`;
assert.equal(path, "/api/services/9router/provider-expose");
});
});
describe("NinerouterModelList — models endpoint", () => {
it("models route path is correct", () => {
const NAME = "9router";
const path = `/api/services/${NAME}/models`;
assert.equal(path, "/api/services/9router/models");
});
it("refresh query param appended when refresh=true", () => {
const NAME = "9router";
const url = `/api/services/${NAME}/models?refresh=true`;
assert.ok(url.includes("?refresh=true"));
});
});
// ── retain existing tests ─────────────────────────────────────────────────────
describe("ApiKeyCard — rotate-key endpoint", () => {
it("rotate-key route path is correct", () => {
const NAME = "9router";
const path = `/api/services/${NAME}/rotate-key`;
assert.equal(path, "/api/services/9router/rotate-key");
});
});
describe("AutoStartCard — auto-start endpoint", () => {
it("auto-start route path is correct", () => {
const NAME = "9router";
const path = `/api/services/${NAME}/auto-start`;
assert.equal(path, "/api/services/9router/auto-start");
});
});
@@ -0,0 +1,78 @@
/**
* T-12 — Services page shell unit tests.
*
* Tests the static configuration (tab list, sidebar item, hooks contract)
* without requiring a browser or Next.js router mock.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
// ── sidebar registration ──────────────────────────────────────────────────────
describe("sidebarVisibility — embedded-services", () => {
it("embedded-services is in HIDEABLE_SIDEBAR_ITEM_IDS", async () => {
const { HIDEABLE_SIDEBAR_ITEM_IDS } =
await import("../../../../../src/shared/constants/sidebarVisibility.ts");
assert.ok(
(HIDEABLE_SIDEBAR_ITEM_IDS as readonly string[]).includes("embedded-services"),
"expected embedded-services in HIDEABLE_SIDEBAR_ITEM_IDS"
);
});
it("embedded-services has the correct href", async () => {
const { SIDEBAR_SECTIONS } =
await import("../../../../../src/shared/constants/sidebarVisibility.ts");
const omniProxy = SIDEBAR_SECTIONS.find((s) => s.id === "omni-proxy");
const flat = (omniProxy?.children ?? []).filter((c) => !("type" in c));
const item = flat.find((c) => (c as { id: string }).id === "embedded-services") as
| { id: string; href: string }
| undefined;
assert.ok(item, "embedded-services item should exist in omni-proxy section");
assert.equal(item.href, "/dashboard/providers/services");
});
});
// ── useServiceStatus contract ─────────────────────────────────────────────────
describe("useServiceStatus — module shape", () => {
it("exports useServiceStatus function", async () => {
const mod =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/hooks/useServiceStatus.ts");
assert.equal(typeof mod.useServiceStatus, "function");
});
});
// ── useServiceLogs contract ───────────────────────────────────────────────────
describe("useServiceLogs — module shape", () => {
it("exports useServiceLogs function", async () => {
const mod =
await import("../../../../../src/app/(dashboard)/dashboard/providers/services/hooks/useServiceLogs.ts");
assert.equal(typeof mod.useServiceLogs, "function");
});
});
// ── tab config ────────────────────────────────────────────────────────────────
describe("tab configuration", () => {
it("defaults to cliproxy tab when ?tab param is absent", () => {
const DEFAULT_TAB = "cliproxy";
// Mirrors the default in page.tsx: sp.get("tab") ?? "cliproxy"
const active = (null ?? DEFAULT_TAB) as string;
assert.equal(active, "cliproxy");
});
it("respects ?tab=9router param", () => {
const param = "9router";
const active = param ?? "cliproxy";
assert.equal(active, "9router");
});
it("URL construction is correct for both tabs", () => {
for (const tab of ["cliproxy", "9router"]) {
const url = `/dashboard/providers/services?tab=${tab}`;
assert.ok(url.includes(tab), `URL should include ${tab}`);
}
});
});