467 lines
17 KiB
TypeScript
467 lines
17 KiB
TypeScript
// @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/);
|
|
});
|
|
});
|