mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
feat(batch): add concept card, New batch button, cost/expiration columns, row actions, 30s polling on /batch (F6)
This commit is contained in:
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Cancel — only for non-terminal statuses */}
|
||||
{canCancel && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (window.confirm(t("batchActionCancel") + "?")) {
|
||||
await actions.cancel(batch.id);
|
||||
}
|
||||
}}
|
||||
disabled={actions.cancelling}
|
||||
title={t("batchActionCancel")}
|
||||
className="flex items-center justify-center p-1 rounded text-[var(--color-text-muted)] hover:text-orange-400 hover:bg-orange-500/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">
|
||||
{actions.cancelling ? "hourglass_empty" : "block"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Download output */}
|
||||
{batch.outputFileId && (
|
||||
<a
|
||||
href={actions.downloadHrefOutput(batch.outputFileId) ?? "#"}
|
||||
download={`batch-${batch.id}-output.jsonl`}
|
||||
onClick={(e) => 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"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">download</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Download errors */}
|
||||
{batch.errorFileId && (
|
||||
<a
|
||||
href={actions.downloadHrefErrors(batch.errorFileId) ?? "#"}
|
||||
download={`batch-${batch.id}-errors.jsonl`}
|
||||
onClick={(e) => 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"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">file_download</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Retry failed — only when terminal + has error file + has failures */}
|
||||
{canRetry && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (
|
||||
window.confirm(
|
||||
t("batchActionRetryConfirm", { n: batch.requestCountsFailed, cost: "TBD" }),
|
||||
)
|
||||
) {
|
||||
await actions.retry({
|
||||
id: batch.id,
|
||||
inputFileId: batch.inputFileId,
|
||||
errorFileId: batch.errorFileId,
|
||||
endpoint: batch.endpoint,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={actions.retrying}
|
||||
title={t("batchActionRetry")}
|
||||
className="flex items-center justify-center p-1 rounded text-[var(--color-text-muted)] hover:text-blue-400 hover:bg-blue-500/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">
|
||||
{actions.retrying ? "hourglass_empty" : "replay"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete — only for terminal statuses */}
|
||||
{isTerminal && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setDeletingId(batch.id);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/batches/${batch.id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
onRefresh?.();
|
||||
} else {
|
||||
console.error(
|
||||
"[BatchRowActions] DELETE returned non-ok status",
|
||||
batch.id,
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[BatchRowActions] DELETE threw", batch.id, err);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}}
|
||||
disabled={deletingId === batch.id}
|
||||
title={t("batchListDeleteBatchTitle")}
|
||||
className="flex items-center justify-center p-1 rounded text-[var(--color-text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">
|
||||
{deletingId === batch.id ? "hourglass_empty" : "delete"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{/* Table — 9 columns: Status | ID | Endpoint | Model | Progress | Cost | Created | Expires | Actions */}
|
||||
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-[var(--color-border)]">
|
||||
<table className="w-full text-sm" role="table" aria-label={t("batchListBatchesTable")}>
|
||||
<thead>
|
||||
@@ -255,6 +377,9 @@ export default function BatchListTab({
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
|
||||
Progress
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
|
||||
{t("batchListCostColumn")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
@@ -267,7 +392,7 @@ export default function BatchListTab({
|
||||
<tbody>
|
||||
{loading && filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
|
||||
<td colSpan={9} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-[var(--color-accent)]" />
|
||||
Loading…
|
||||
@@ -276,7 +401,7 @@ export default function BatchListTab({
|
||||
</tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
|
||||
<td colSpan={9} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
|
||||
No batches found
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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 (
|
||||
<tr
|
||||
key={batch.id}
|
||||
@@ -307,52 +450,45 @@ export default function BatchListTab({
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] text-xs">
|
||||
{batch.model ?? "—"}
|
||||
</td>
|
||||
{/* Progress — ProgressBarBicolor from F3 */}
|
||||
<td className="px-4 py-3 min-w-[140px]">
|
||||
{total > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-[var(--color-text-muted)]">
|
||||
<span>
|
||||
<span className="text-emerald-400">{done}</span>
|
||||
{failed > 0 && <span className="text-red-400"> / {failed} err</span>}
|
||||
<span> / {total}</span>
|
||||
</span>
|
||||
<span>{Math.round(donePct + failedPct)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-[var(--color-bg-alt)] overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${donePct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-red-500 transition-all"
|
||||
style={{ width: `${failedPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ProgressBarBicolor
|
||||
total={total}
|
||||
completed={done}
|
||||
failed={failed}
|
||||
showLabels
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
</td>
|
||||
{/* Cost column — heuristic estimate (D8) */}
|
||||
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
|
||||
{estimatedCost}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
|
||||
{relativeTime(batch.createdAt)}
|
||||
</td>
|
||||
{/* Expiration — countdown badge for active batches (D11) */}
|
||||
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
|
||||
{batch.expiresAt ? relativeTime(batch.expiresAt) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{["completed", "failed", "cancelled", "expired"].includes(batch.status) && (
|
||||
<button
|
||||
onClick={(e) => handleDeleteBatch(e, batch)}
|
||||
disabled={deletingId === batch.id}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-500/10 border border-red-500/25 text-red-400 hover:text-red-300 transition-colors whitespace-nowrap disabled:opacity-50"
|
||||
title={t("batchListDeleteBatchTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">
|
||||
{deletingId === batch.id ? "hourglass_empty" : "delete"}
|
||||
</span>
|
||||
</button>
|
||||
{["in_progress", "validating", "finalizing"].includes(batch.status) ? (
|
||||
<ExpirationBadge expiresAt={batch.expiresAt ?? null} variant="compact" />
|
||||
) : batch.expiresAt ? (
|
||||
relativeTime(batch.expiresAt)
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
{/* Actions — cancel / download / retry / delete */}
|
||||
<td className="px-4 py-3">
|
||||
<BatchRowActions
|
||||
batch={batch}
|
||||
onRefresh={onRefresh}
|
||||
deletingId={deletingId}
|
||||
setDeletingId={setDeletingId}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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<NewBatchWizardProps>) {
|
||||
const t = useTranslations("common");
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("wizardTitle")}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
>
|
||||
<div className="relative w-full sm:max-w-3xl bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl shadow-xl flex flex-col gap-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-base text-[var(--color-text-main)]">
|
||||
{t("wizardTitle")}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors"
|
||||
aria-label={t("wizardClose")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
Wizard coming soon — F4 delivers the full implementation.
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-main)] transition-colors"
|
||||
>
|
||||
{t("wizardCancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
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, unknown>) => string;
|
||||
}): UseBatchActionsResult {
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ── cancel ────────────────────────────────────────────────────────────────
|
||||
|
||||
const cancel = useCallback(
|
||||
async (batchId: string): Promise<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, string[]> = {
|
||||
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<string, string> = {
|
||||
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<Array<{ id: string; name: string; models: string[] }>>(
|
||||
[],
|
||||
);
|
||||
|
||||
const [batchesHasMore, setBatchesHasMore] = useState(false);
|
||||
const [batchesLastId, setBatchesLastId] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-end gap-4 flex-wrap">
|
||||
<button
|
||||
onClick={() => fetchData(false)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg
|
||||
bg-surface border border-border
|
||||
text-text-secondary hover:text-text-primary
|
||||
hover:border-primary transition-all duration-200
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
||||
{loading ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
{/* Concept card (F3) */}
|
||||
<BatchConceptCard />
|
||||
|
||||
{/* Toolbar: auto-refresh indicator + Refresh + New batch */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="material-symbols-outlined text-[14px]">refresh</span>
|
||||
{t("batchListAutoRefresh")}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => fetchData(false)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg
|
||||
bg-[var(--color-surface)] border border-[var(--color-border)]
|
||||
text-[var(--color-text-secondary)] hover:text-[var(--color-text-main)]
|
||||
hover:border-[var(--color-accent)] transition-all duration-200
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
||||
{loading ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowWizard(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg
|
||||
bg-[var(--color-accent)] text-white hover:opacity-90
|
||||
transition-all duration-200"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">add</span>
|
||||
{t("batchListNewButton")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Batch list + infinite scroll sentinel */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<BatchListTab
|
||||
batches={batches}
|
||||
@@ -177,6 +286,18 @@ export default function BatchPage() {
|
||||
)}
|
||||
<div ref={bottomRefBatches} className="h-10" />
|
||||
</div>
|
||||
|
||||
{/* New batch wizard (F4 stub — replaced by F4 full implementation on merge) */}
|
||||
{showWizard && (
|
||||
<NewBatchWizard
|
||||
onClose={() => setShowWizard(false)}
|
||||
onCreated={(_id) => {
|
||||
setShowWizard(false);
|
||||
void fetchData(false);
|
||||
}}
|
||||
availableProviders={providers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
395
tests/unit/dashboard/batch/components/useBatchActions.test.tsx
Normal file
395
tests/unit/dashboard/batch/components/useBatchActions.test.tsx
Normal file
@@ -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("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<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("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 \//);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user