diff --git a/src/app/(dashboard)/dashboard/batch/BatchListTab.tsx b/src/app/(dashboard)/dashboard/batch/BatchListTab.tsx index 4341c88422..808814a529 100644 --- a/src/app/(dashboard)/dashboard/batch/BatchListTab.tsx +++ b/src/app/(dashboard)/dashboard/batch/BatchListTab.tsx @@ -3,6 +3,11 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; import BatchDetailModal from "./BatchDetailModal"; +import ExpirationBadge from "./components/ExpirationBadge"; +import ProgressBarBicolor from "./components/ProgressBarBicolor"; +import { useBatchActions } from "./components/useBatchActions"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── function relativeTime(ts: number): string { const diffMs = Date.now() - ts * 1000; @@ -26,6 +31,8 @@ function relativeTime(ts: number): string { return `${res} ago`; } +// ── Types ───────────────────────────────────────────────────────────────────── + interface BatchRecord { id: string; endpoint: string; @@ -69,6 +76,8 @@ interface BatchListTabProps { onRefresh?: () => void; } +// ── Status helpers ──────────────────────────────────────────────────────────── + const STATUS_STYLES: Record = { completed: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25", completed_with_failures: "bg-red-500/15 text-red-400 border-red-500/25", @@ -91,7 +100,7 @@ const STATUS_LABELS: Record = { cancelled_with_failures: "cancelled with failures", }; -/** Returns a composite key that reflects whether partial failures occurred. */ +/** Returns a composite status key that reflects whether partial failures occurred. */ function effectiveStatus(batch: BatchRecord): string { const hasFailed = (batch.requestCountsFailed ?? 0) > 0; if (!hasFailed) return batch.status; @@ -127,6 +136,139 @@ const ALL_STATUSES = [ "expired", ]; +// ── BatchRowActions (internal component) ────────────────────────────────────── + +/** Per-row action buttons: cancel, download output/errors, retry, delete. */ +function BatchRowActions({ + batch, + onRefresh, + deletingId, + setDeletingId, +}: Readonly<{ + batch: BatchRecord; + onRefresh?: () => void; + deletingId: string | null; + setDeletingId: (id: string | null) => void; +}>) { + const t = useTranslations("common"); + const actions = useBatchActions({ onRefresh, t }); + + const isTerminal = ["completed", "failed", "cancelled", "expired"].includes(batch.status); + const canCancel = ["validating", "in_progress", "finalizing"].includes(batch.status); + const canRetry = + isTerminal && !!batch.errorFileId && (batch.requestCountsFailed ?? 0) > 0; + + return ( +
e.stopPropagation()}> + {/* Cancel — only for non-terminal statuses */} + {canCancel && ( + + )} + + {/* Download output */} + {batch.outputFileId && ( + e.stopPropagation()} + title={t("batchActionDownloadOutput")} + className="flex items-center justify-center p-1 rounded text-[var(--color-text-muted)] hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors" + > + download + + )} + + {/* Download errors */} + {batch.errorFileId && ( + e.stopPropagation()} + title={t("batchActionDownloadErrors")} + className="flex items-center justify-center p-1 rounded text-[var(--color-text-muted)] hover:text-yellow-400 hover:bg-yellow-500/10 transition-colors" + > + file_download + + )} + + {/* Retry failed — only when terminal + has error file + has failures */} + {canRetry && ( + + )} + + {/* Delete — only for terminal statuses */} + {isTerminal && ( + + )} +
+ ); +} + +// ── BatchListTab ────────────────────────────────────────────────────────────── + export default function BatchListTab({ batches, files, @@ -143,26 +285,6 @@ export default function BatchListTab({ const completedBatches = batches.filter((b) => b.status === "completed"); - const handleDeleteBatch = async (e: React.MouseEvent, batch: BatchRecord) => { - e.stopPropagation(); - setDeletingId(batch.id); - try { - const res = await fetch(`/api/v1/batches/${batch.id}`, { method: "DELETE" }); - if (res.ok) { - onRefresh?.(); - } else { - console.error( - `[DeleteBatch] DELETE ${batch.id} returned ${res.status}`, - await res.text().catch(() => "") - ); - } - } catch (err) { - console.error(`[DeleteBatch] DELETE ${batch.id} threw`, err); - } finally { - setDeletingId(null); - } - }; - const handleRemoveCompleted = async () => { if (completedBatches.length === 0) return; setRemovingCompleted(true); @@ -172,13 +294,13 @@ export default function BatchListTab({ onRefresh?.(); } else { console.error( - "[RemoveCompleted] DELETE /batches/delete-completed returned", + "[BatchListTab] DELETE /batches/delete-completed returned", res.status, - await res.text().catch(() => "") + await res.text().catch(() => ""), ); } } catch (err) { - console.error("[RemoveCompleted] DELETE /batches/delete-completed threw", err); + console.error("[BatchListTab] DELETE /batches/delete-completed threw", err); } finally { setRemovingCompleted(false); } @@ -235,7 +357,7 @@ export default function BatchListTab({ - {/* Table */} + {/* Table — 9 columns: Status | ID | Endpoint | Model | Progress | Cost | Created | Expires | Actions */}
@@ -255,6 +377,9 @@ export default function BatchListTab({ + @@ -267,7 +392,7 @@ export default function BatchListTab({ {loading && filtered.length === 0 ? ( - ) : filtered.length === 0 ? ( - @@ -285,8 +410,26 @@ export default function BatchListTab({ const total = batch.requestCountsTotal || 0; const done = batch.requestCountsCompleted || 0; const failed = batch.requestCountsFailed || 0; - const donePct = total > 0 ? (done / total) * 100 : 0; - const failedPct = total > 0 ? (failed / total) * 100 : 0; + + // Cost estimate — lightweight heuristic (D8). + // Full per-request estimate (using JSONL input) is shown in BatchDetailModal. + const estimatedCost = (() => { + if (!batch.model || total === 0) return "—"; + // Prefer real usage data when available (completed batches) + const usage = batch.usage as + | { input_tokens?: number; output_tokens?: number } + | null + | undefined; + if (usage?.input_tokens != null && usage?.output_tokens != null) { + // batch rate ≈ $0.005/1K tokens (blended, already -50%) + const cost = ((usage.input_tokens + usage.output_tokens) * 0.005) / 1000; + return `~$${cost.toFixed(2)}`; + } + // Fallback heuristic: 500 avg tokens/request × batch rate + const estCost = (total * 500 * 0.005) / 1000; + return `~$${estCost.toFixed(2)}`; + })(); + return ( {batch.model ?? "—"} + {/* Progress — ProgressBarBicolor from F3 */} + {/* Cost column — heuristic estimate (D8) */} + + {/* Expiration — countdown badge for active batches (D11) */} - + {/* Actions — cancel / download / retry / delete */} + ); }) diff --git a/src/app/(dashboard)/dashboard/batch/components/NewBatchWizard.tsx b/src/app/(dashboard)/dashboard/batch/components/NewBatchWizard.tsx new file mode 100644 index 0000000000..1c1aa4e667 --- /dev/null +++ b/src/app/(dashboard)/dashboard/batch/components/NewBatchWizard.tsx @@ -0,0 +1,61 @@ +"use client"; + +/** + * NewBatchWizard — Stub placeholder. + * + * F4 (feat/batch-files-20-F4-wizard) delivers the full 4-step implementation. + * This file exists so F6 can import and render it without blocking on F4. + * F4 MUST replace this file with the full wizard when it lands. + * + * Props contract (canonical — matches §3.4 of master-plan-20): + * onClose: () => void + * onCreated: (batchId: string) => void + * availableProviders: Array<{ id: string; name: string; models: string[] }> + */ + +import { useTranslations } from "next-intl"; + +interface NewBatchWizardProps { + onClose: () => void; + onCreated: (batchId: string) => void; + availableProviders: Array<{ id: string; name: string; models: string[] }>; +} + +export default function NewBatchWizard({ onClose }: Readonly) { + const t = useTranslations("common"); + + return ( +
+
+
+ + {t("wizardTitle")} + + +
+

+ Wizard coming soon — F4 delivers the full implementation. +

+
+ +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/batch/components/useBatchActions.ts b/src/app/(dashboard)/dashboard/batch/components/useBatchActions.ts new file mode 100644 index 0000000000..050761568d --- /dev/null +++ b/src/app/(dashboard)/dashboard/batch/components/useBatchActions.ts @@ -0,0 +1,198 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { buildRetryPlan } from "@/lib/batches/retryFailed"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface BatchActionsState { + cancelling: boolean; + retrying: boolean; + error: string | null; +} + +export interface UseBatchActionsResult extends BatchActionsState { + cancel: (batchId: string) => Promise; + retry: (batch: { + id: string; + inputFileId: string; + errorFileId?: string | null; + endpoint: string; + }) => Promise<{ newBatchId: string } | null>; + downloadHrefOutput: (outputFileId: string | null | undefined) => string | null; + downloadHrefErrors: (errorFileId: string | null | undefined) => string | null; +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +/** + * Shared hook for batch row actions: cancel, retry-failed, download output/errors. + * Reused by BatchListTab (F6) and BatchDetailModal (F7). + * + * All error messages use i18n keys via the `t()` passed in opts — never raw err.message/stack. + * Technical errors are only logged via console.error for diagnostics (D14 compliance). + * + * @param opts.onRefresh Optional callback to call after a successful mutating action. + * @param opts.t Translation function (key: string) => string from useTranslations("common"). + */ +export function useBatchActions(opts: { + onRefresh?: () => void; + t: (key: string, params?: Record) => string; +}): UseBatchActionsResult { + const [cancelling, setCancelling] = useState(false); + const [retrying, setRetrying] = useState(false); + const [error, setError] = useState(null); + + // ── cancel ──────────────────────────────────────────────────────────────── + + const cancel = useCallback( + async (batchId: string): Promise => { + setCancelling(true); + setError(null); + try { + const res = await fetch(`/api/v1/batches/${batchId}/cancel`, { method: "POST" }); + if (!res.ok) { + // Log technical context; surface sanitized i18n key to user (D14) + console.error("[useBatchActions] cancel", batchId, "status", res.status); + setError(opts.t("batchActionCancel")); + return; + } + opts.onRefresh?.(); + } catch (e) { + // Never expose e.message/stack in UI — diagnostics only (D14) + console.error("[useBatchActions] cancel threw", e); + setError(opts.t("batchActionCancel")); + } finally { + setCancelling(false); + } + }, + [opts], + ); + + // ── retry ───────────────────────────────────────────────────────────────── + + const retry = useCallback( + async (batch: { + id: string; + inputFileId: string; + errorFileId?: string | null; + endpoint: string; + }): Promise<{ newBatchId: string } | null> => { + // Guard: no error file means nothing to retry + if (!batch.errorFileId) { + return null; + } + + setRetrying(true); + setError(null); + + try { + // 1. Download both input and error files in parallel + const [inputRes, errorRes] = await Promise.all([ + fetch(`/api/v1/files/${batch.inputFileId}/content`), + fetch(`/api/v1/files/${batch.errorFileId}/content`), + ]); + + if (!inputRes.ok || !errorRes.ok) { + console.error( + "[useBatchActions] retry file download failed", + "input", + inputRes.status, + "error", + errorRes.status, + ); + setError(opts.t("batchActionRetry")); + return null; + } + + const inputJsonl = await inputRes.text(); + const errorJsonl = await errorRes.text(); + + // 2. Build retry plan — pure helper, no side effects (D9) + const plan = buildRetryPlan({ inputJsonl, errorJsonl }); + + if (plan.retriableLines === 0) { + // i18n key surfaced to user; technical context stays in console + console.error("[useBatchActions] retry: no retriable lines found for", batch.id); + setError(opts.t("batchActionRetry")); + return null; + } + + // 3. Upload new JSONL file (purpose=batch) + const formData = new FormData(); + formData.append("purpose", "batch"); + formData.append( + "file", + new Blob([plan.newJsonl], { type: "application/jsonl" }), + `retry-${batch.id}.jsonl`, + ); + + const fileRes = await fetch("/api/v1/files", { method: "POST", body: formData }); + if (!fileRes.ok) { + console.error("[useBatchActions] retry file upload failed", fileRes.status); + setError(opts.t("batchActionRetry")); + return null; + } + + const file = (await fileRes.json()) as { id: string }; + + // 4. Create new batch with same endpoint + 24h window (D9) + const batchRes = await fetch("/api/v1/batches", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input_file_id: file.id, + endpoint: batch.endpoint, + completion_window: "24h", + }), + }); + + if (!batchRes.ok) { + console.error("[useBatchActions] retry batch create failed", batchRes.status); + setError(opts.t("batchActionRetry")); + return null; + } + + const newBatch = (await batchRes.json()) as { id: string }; + opts.onRefresh?.(); + return { newBatchId: newBatch.id }; + } catch (e) { + // Never surface e.message/stack to UI (D14) + console.error("[useBatchActions] retry threw", e); + setError(opts.t("batchActionRetry")); + return null; + } finally { + setRetrying(false); + } + }, + [opts], + ); + + // ── download hrefs (pure — no side effects) ─────────────────────────────── + + const downloadHrefOutput = useCallback( + (outputFileId: string | null | undefined): string | null => { + if (!outputFileId) return null; + return `/api/v1/files/${outputFileId}/content`; + }, + [], + ); + + const downloadHrefErrors = useCallback( + (errorFileId: string | null | undefined): string | null => { + if (!errorFileId) return null; + return `/api/v1/files/${errorFileId}/content`; + }, + [], + ); + + return { + cancelling, + retrying, + error, + cancel, + retry, + downloadHrefOutput, + downloadHrefErrors, + }; +} diff --git a/src/app/(dashboard)/dashboard/batch/page.tsx b/src/app/(dashboard)/dashboard/batch/page.tsx index 7adb6d0690..7e9c811bb2 100644 --- a/src/app/(dashboard)/dashboard/batch/page.tsx +++ b/src/app/(dashboard)/dashboard/batch/page.tsx @@ -6,6 +6,24 @@ import BatchListTab from "./BatchListTab"; import { FileRecord } from "@/lib/db/files"; import { BatchRecord } from "@/lib/db/batches"; import { mapBatchApiToRecord, mapFileApiToRecord } from "./batch-utils"; +import BatchConceptCard from "./components/BatchConceptCard"; +import NewBatchWizard from "./components/NewBatchWizard"; + +// ── Batch-capable providers (D16) ───────────────────────────────────────────── + +const BATCH_SUPPORTED = ["openai", "anthropic", "gemini"]; +const MODEL_DEFAULTS: Record = { + openai: ["gpt-4o-mini", "gpt-4o", "gpt-4-turbo"], + anthropic: ["claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022"], + gemini: ["gemini-1.5-flash", "gemini-1.5-pro"], +}; +const PROVIDER_NAMES: Record = { + openai: "OpenAI", + anthropic: "Anthropic", + gemini: "Gemini", +}; + +// ── Component ───────────────────────────────────────────────────────────────── export default function BatchPage() { const t = useTranslations("common"); @@ -14,6 +32,10 @@ export default function BatchPage() { const [batchesTotal, setBatchesTotal] = useState(0); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); + const [showWizard, setShowWizard] = useState(false); + const [providers, setProviders] = useState>( + [], + ); const [batchesHasMore, setBatchesHasMore] = useState(false); const [batchesLastId, setBatchesLastId] = useState(null); @@ -46,17 +68,16 @@ export default function BatchPage() { setBatchesHasMore(Boolean(data.has_more)); setBatchesLastId(data.last_id || null); } else if (isBackground) { - // Background refresh: merge new items with existing ones, preserve pagination state + // Background refresh: merge new items, preserve pagination state setBatches((prev) => { const batchMap = new Map(prev.map((b) => [b.id, b])); for (const m of mapped) { batchMap.set(m.id, m); } return Array.from(batchMap.values()).sort( - (a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id) + (a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id), ); }); - // Don't reset batchesLastId or batchesHasMore on background refresh } else { setBatches(mapped); setBatchesHasMore(Boolean(data.has_more)); @@ -75,7 +96,7 @@ export default function BatchPage() { fileMap.set(m.id, m); } return Array.from(fileMap.values()).sort( - (a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id) + (a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id), ); }); } else { @@ -83,14 +104,14 @@ export default function BatchPage() { } } } catch (error) { - console.error("Failed to fetch batches/files", error); + console.error("[BatchPage] fetchData threw", error); } finally { isFetchingRef.current = false; if (!isBackground) setLoading(false); if (opts.appendBatches) setLoadingMore(false); } }, - [batchesLastId] + [batchesLastId], ); // Keep fetchData ref in sync @@ -98,22 +119,66 @@ export default function BatchPage() { fetchDataRef.current = fetchData; }, [fetchData]); - // Track loadingMore in a ref for use in observer callback (avoids re-creating observer) + // Track loadingMore in a ref for use in observer callback const loadingMoreRef = useRef(loadingMore); useEffect(() => { loadingMoreRef.current = loadingMore; }, [loadingMore]); - // Initial fetch and background refresh timer (runs once on mount) + // Fetch available batch-capable providers on mount (D16). + // Intersects /api/providers with BATCH_SUPPORTED list. + // Falls back to hardcoded list if route errors or returns empty. + useEffect(() => { + const load = async () => { + try { + const res = await fetch("/api/providers"); + if (res.ok) { + const data = (await res.json()) as { + connections: Array<{ provider: string; is_active?: boolean }>; + }; + const connected = new Set( + (data.connections ?? []) + .filter((c) => BATCH_SUPPORTED.includes(c.provider)) + .map((c) => c.provider), + ); + if (connected.size > 0) { + setProviders( + Array.from(connected).map((id) => ({ + id, + name: PROVIDER_NAMES[id] ?? id, + models: MODEL_DEFAULTS[id] ?? [], + })), + ); + return; + } + } + } catch (e) { + console.error("[BatchPage] providers fetch error", e); + } + // Fallback: hardcoded list per D16 + setProviders( + BATCH_SUPPORTED.map((id) => ({ + id, + name: PROVIDER_NAMES[id] ?? id, + models: MODEL_DEFAULTS[id] ?? [], + })), + ); + }; + void load(); + }, []); + + // Initial fetch + 30s polling (D10). Pauses when tab is hidden. useEffect(() => { const scheduleRefresh = () => { refreshTimeoutRef.current = setTimeout(async () => { - await fetchDataRef.current?.(true); + if (!document.hidden) { + await fetchDataRef.current?.(true); + } scheduleRefresh(); - }, 10_000); + }, 30_000); }; - // Initial fetch (with loading) + // Initial fetch (with loading indicator) fetchDataRef.current?.(); // Schedule background refreshes scheduleRefresh(); @@ -124,7 +189,32 @@ export default function BatchPage() { refreshTimeoutRef.current = null; } }; - }, []); // Empty deps - only run once, uses ref for latest fetchData + }, []); + + // Pause/resume polling on visibility change (D10) + useEffect(() => { + const handleVisibilityChange = () => { + if (document.hidden) { + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + refreshTimeoutRef.current = null; + } + } else if (!refreshTimeoutRef.current) { + // Resume polling + const scheduleRefresh = () => { + refreshTimeoutRef.current = setTimeout(async () => { + if (!document.hidden) { + await fetchDataRef.current?.(true); + } + scheduleRefresh(); + }, 30_000); + }; + scheduleRefresh(); + } + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => document.removeEventListener("visibilitychange", handleVisibilityChange); + }, []); // IntersectionObserver for infinite scroll on batches useEffect(() => { @@ -134,7 +224,7 @@ export default function BatchPage() { fetchDataRef.current?.(true, { appendBatches: true }); } }, - { threshold: 0.1 } + { threshold: 0.1 }, ); if (bottomRefBatches.current) { @@ -148,22 +238,41 @@ export default function BatchPage() { return (
- {/* Toolbar */} -
- + {/* Concept card (F3) */} + + + {/* Toolbar: auto-refresh indicator + Refresh + New batch */} +
+
+ refresh + {t("batchListAutoRefresh")} +
+
+ + +
+ {/* Batch list + infinite scroll sentinel */}
+ + {/* New batch wizard (F4 stub — replaced by F4 full implementation on merge) */} + {showWizard && ( + setShowWizard(false)} + onCreated={(_id) => { + setShowWizard(false); + void fetchData(false); + }} + availableProviders={providers} + /> + )}
); } diff --git a/tests/unit/dashboard/batch/components/useBatchActions.test.tsx b/tests/unit/dashboard/batch/components/useBatchActions.test.tsx new file mode 100644 index 0000000000..f86283c4d5 --- /dev/null +++ b/tests/unit/dashboard/batch/components/useBatchActions.test.tsx @@ -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; + +/** 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; 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(); + }); + containers.push({ root, el }); + + return { get: () => latestResult }; +} + +// ── Setup / teardown ────────────────────────────────────────────────────────── + +const containers: Array<{ root: ReturnType; 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("batchActionCancel"); + 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("batchActionCancel"); + 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("batchActionRetry"); + 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).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("batchActionRetry"); + 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("batchActionRetry"); + 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("batchActionCancel"); + 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 \//); + }); +});
Progress + {t("batchListCostColumn")} + Created
+
Loading… @@ -276,7 +401,7 @@ export default function BatchListTab({
+ No batches found
{total > 0 ? ( -
-
- - {done} - {failed > 0 && / {failed} err} - / {total} - - {Math.round(donePct + failedPct)}% -
-
-
-
-
-
+ ) : ( )}
+ {estimatedCost} + {relativeTime(batch.createdAt)} - {batch.expiresAt ? relativeTime(batch.expiresAt) : "—"} - - {["completed", "failed", "cancelled", "expired"].includes(batch.status) && ( - + {["in_progress", "validating", "finalizing"].includes(batch.status) ? ( + + ) : batch.expiresAt ? ( + relativeTime(batch.expiresAt) + ) : ( + "—" )} + +