Merge F4 (NewBatchWizard 4-step modal + tests) into orchestrator branch

# Conflicts:
#	src/app/(dashboard)/dashboard/batch/components/NewBatchWizard.tsx
This commit is contained in:
diegosouzapw
2026-05-27 22:11:12 -03:00
7 changed files with 1713 additions and 34 deletions

View File

@@ -1,19 +1,143 @@
"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 { useEffect, useReducer } from "react";
import { useTranslations } from "next-intl";
import DestinationStep from "./wizard/DestinationStep";
import InputStep from "./wizard/InputStep";
import JsonlValidationStep from "./wizard/JsonlValidationStep";
import CostEstimateStep from "./wizard/CostEstimateStep";
import type { WizardDestination, WizardInput, ValidationResult, CostEstimate } from "@/lib/batches/types";
// ── State ─────────────────────────────────────────────────────────────────────
interface WizardState {
step: 1 | 2 | 3 | 4;
destination: WizardDestination | null;
input: WizardInput;
validation: ValidationResult | null;
cost: CostEstimate | null;
creating: boolean;
error: string | null;
}
const initialInput: WizardInput = { kind: "jsonl", fileName: null, rawContent: null };
const initialState: WizardState = {
step: 1,
destination: null,
input: initialInput,
validation: null,
cost: null,
creating: false,
error: null,
};
// ── Actions ───────────────────────────────────────────────────────────────────
type WizardAction =
| { type: "SET_DESTINATION"; destination: WizardDestination | null }
| { type: "SET_INPUT"; input: WizardInput }
| { type: "SET_VALIDATION_RESULT"; result: ValidationResult }
| { type: "SET_COST_ESTIMATE"; cost: CostEstimate }
| { type: "SET_STEP"; step: 1 | 2 | 3 | 4 }
| { type: "SET_CREATING"; creating: boolean }
| { type: "SET_ERROR"; error: string | null }
| { type: "RESET" };
function reducer(state: WizardState, action: WizardAction): WizardState {
switch (action.type) {
case "SET_DESTINATION":
return { ...state, destination: action.destination, error: null };
case "SET_INPUT":
return { ...state, input: action.input, validation: null, error: null };
case "SET_VALIDATION_RESULT":
return { ...state, validation: action.result, error: null };
case "SET_COST_ESTIMATE":
return { ...state, cost: action.cost };
case "SET_STEP":
return { ...state, step: action.step, error: null };
case "SET_CREATING":
return { ...state, creating: action.creating };
case "SET_ERROR":
return { ...state, error: action.error, creating: false };
case "RESET":
return { ...initialState };
default:
return state;
}
}
// ── Step completion guards ────────────────────────────────────────────────────
function isStep1Valid(state: WizardState): boolean {
return (
state.destination !== null &&
state.destination.provider.length > 0 &&
state.destination.model.length > 0
);
}
function isStep2Valid(state: WizardState): boolean {
if (!state.input.rawContent) return false;
if (state.input.kind === "csv") {
// CSV: rawContent is updated to the generated JSONL by CsvMappingStep
// We detect this by checking the content is valid-looking JSONL (not raw CSV)
// Simplest heuristic: must contain at least one JSON object line
return state.input.rawContent.trim().startsWith("{");
}
return true;
}
function isStep3Valid(state: WizardState): boolean {
return state.validation !== null && state.validation.ok;
}
// ── Stepper indicator ─────────────────────────────────────────────────────────
const STEPS = [1, 2, 3, 4] as const;
const STEP_LABELS: Record<number, string> = {
1: "wizardStep1Destination",
2: "wizardStep2Input",
3: "wizardStep3Validate",
4: "wizardStep4Cost",
};
function StepIndicator({ current, t }: { current: 1 | 2 | 3 | 4; t: ReturnType<typeof useTranslations<"common">> }) {
return (
<div className="flex items-center gap-1 sm:gap-2">
{STEPS.map((s) => {
const isDone = s < current;
const isCurrent = s === current;
return (
<div key={s} className="flex items-center gap-1 sm:gap-2">
<div
className={`flex items-center justify-center w-6 h-6 rounded-full text-xs font-medium transition-colors
${isDone ? "bg-emerald-500 text-white" : isCurrent ? "bg-[var(--color-accent)] text-white" : "bg-[var(--color-border)] text-[var(--color-text-muted)]"}`}
>
{isDone ? (
<span className="material-symbols-outlined text-sm">check</span>
) : (
s
)}
</div>
<span
className={`hidden sm:inline text-xs ${isCurrent ? "text-[var(--color-text)]" : "text-[var(--color-text-muted)]"}`}
>
{t(STEP_LABELS[s] as Parameters<typeof t>[0])}
</span>
{s < 4 && (
<div
className={`w-6 h-px ${isDone ? "bg-emerald-500" : "bg-[var(--color-border)]"}`}
/>
)}
</div>
);
})}
</div>
);
}
// ── Props ─────────────────────────────────────────────────────────────────────
interface NewBatchWizardProps {
onClose: () => void;
@@ -21,39 +145,208 @@ interface NewBatchWizardProps {
availableProviders: Array<{ id: string; name: string; models: string[] }>;
}
export default function NewBatchWizard({ onClose }: Readonly<NewBatchWizardProps>) {
// ── Component ─────────────────────────────────────────────────────────────────
export default function NewBatchWizard({
onClose,
onCreated,
availableProviders,
}: NewBatchWizardProps) {
const t = useTranslations("common");
const [state, dispatch] = useReducer(reducer, initialState);
// Escape key handler
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && !state.creating) onClose();
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [onClose, state.creating]);
// ── Navigation ──────────────────────────────────────────────────────────────
function handleNext() {
if (state.step < 4) {
dispatch({ type: "SET_STEP", step: (state.step + 1) as 1 | 2 | 3 | 4 });
}
}
function handleBack() {
if (state.step > 1) {
dispatch({ type: "SET_STEP", step: (state.step - 1) as 1 | 2 | 3 | 4 });
}
}
// ── Batch creation ──────────────────────────────────────────────────────────
async function handleCreate() {
if (!state.destination || !state.input.rawContent) return;
dispatch({ type: "SET_CREATING", creating: true });
dispatch({ type: "SET_ERROR", error: null });
try {
// Step 1: upload input file
const formData = new FormData();
formData.append("purpose", "batch");
formData.append(
"file",
new Blob([state.input.rawContent], { type: "application/jsonl" }),
"batch-input.jsonl"
);
const fileRes = await fetch("/api/v1/files", { method: "POST", body: formData });
if (!fileRes.ok) {
console.error("[NewBatchWizard] file upload failed:", fileRes.status);
dispatch({ type: "SET_ERROR", error: t("wizardErrorUpload") });
return;
}
const file = (await fileRes.json()) as { id: string };
// Step 2: create batch
const batchRes = await fetch("/api/v1/batches", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
input_file_id: file.id,
endpoint: state.destination.endpoint,
completion_window: "24h",
}),
});
if (!batchRes.ok) {
console.error("[NewBatchWizard] batch create failed:", batchRes.status);
dispatch({ type: "SET_ERROR", error: t("wizardErrorCreate") });
return;
}
const batch = (await batchRes.json()) as { id: string };
onCreated(batch.id);
onClose();
} catch (err) {
// Hard rule #12 — never expose err.message/stack raw
console.error("[NewBatchWizard] unexpected error:", err);
dispatch({ type: "SET_ERROR", error: t("wizardErrorCreate") });
}
}
// ── Next button state ───────────────────────────────────────────────────────
const canGoNext =
(state.step === 1 && isStep1Valid(state)) ||
(state.step === 2 && isStep2Valid(state)) ||
(state.step === 3 && isStep3Valid(state));
// ── Step content ────────────────────────────────────────────────────────────
const stepContent = (() => {
switch (state.step) {
case 1:
return (
<DestinationStep
destination={state.destination}
onChange={(dest) => dispatch({ type: "SET_DESTINATION", destination: dest })}
availableProviders={availableProviders}
/>
);
case 2:
return (
<InputStep
input={state.input}
onChange={(inp) => dispatch({ type: "SET_INPUT", input: inp })}
destination={state.destination}
/>
);
case 3:
return (
<JsonlValidationStep
jsonl={state.input.rawContent ?? ""}
endpoint={state.destination?.endpoint ?? "/v1/chat/completions"}
onResult={(r) => dispatch({ type: "SET_VALIDATION_RESULT", result: r })}
/>
);
case 4:
return (
<CostEstimateStep
jsonl={state.input.rawContent ?? ""}
model={state.destination?.model ?? ""}
endpoint={state.destination?.endpoint ?? "/v1/chat/completions"}
onCreate={handleCreate}
creating={state.creating}
error={state.error}
/>
);
}
})();
// ── Render ──────────────────────────────────────────────────────────────────
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>
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
{/* Overlay */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={() => !state.creating && onClose()}
/>
{/* Panel */}
<div className="relative w-full sm:max-w-3xl max-h-[90vh] flex flex-col rounded-t-2xl sm:rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)] shadow-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-b border-[var(--color-border)] shrink-0">
<div className="flex flex-col gap-2">
<h2 className="text-base font-semibold text-[var(--color-text)]">{t("wizardTitle")}</h2>
<StepIndicator current={state.step} t={t} />
</div>
<button
onClick={onClose}
className="text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors"
type="button"
onClick={() => !state.creating && onClose()}
disabled={state.creating}
aria-label={t("wizardClose")}
className="text-[var(--color-text-muted)] hover:text-[var(--color-text)] disabled:opacity-40 transition-colors"
>
<span className="material-symbols-outlined text-[20px]">close</span>
<span className="material-symbols-outlined">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">
{/* Body */}
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-6">{stepContent}</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-t border-[var(--color-border)] shrink-0">
<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"
type="button"
onClick={() => !state.creating && onClose()}
disabled={state.creating}
className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-text)] disabled:opacity-40 transition-colors"
>
{t("wizardCancel")}
</button>
<div className="flex items-center gap-3">
{state.step > 1 && (
<button
type="button"
onClick={handleBack}
disabled={state.creating}
className="rounded-lg px-4 py-2 text-sm font-medium border border-[var(--color-border)] text-[var(--color-text)] hover:bg-[var(--color-bg-alt)] disabled:opacity-40 transition-colors"
>
{t("wizardBack")}
</button>
)}
{state.step < 4 && (
<button
type="button"
onClick={handleNext}
disabled={!canGoNext}
className="rounded-lg px-4 py-2 text-sm font-medium bg-[var(--color-accent)] text-white disabled:opacity-40 hover:opacity-90 transition-opacity"
>
{t("wizardNext")}
</button>
)}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,157 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { estimateBatchCost } from "@/lib/batches/costEstimator";
import type { CostEstimate } from "@/lib/batches/types";
import type { SupportedBatchEndpoint } from "@/lib/batches/types";
interface CostEstimateStepProps {
jsonl: string;
model: string;
endpoint: SupportedBatchEndpoint;
onCreate: () => void;
creating: boolean;
error: string | null;
}
export default function CostEstimateStep({
jsonl,
model,
endpoint,
onCreate,
creating,
error,
}: CostEstimateStepProps) {
const t = useTranslations("common");
const [estimate, setEstimate] = useState<CostEstimate | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
try {
const est = estimateBatchCost({ jsonl, model, endpoint });
setEstimate(est);
} catch (err) {
console.error("[CostEstimateStep] cost estimation error:", err);
// Fallback: zero-cost estimate so user can still proceed
setEstimate({
model,
totalRequests: 0,
estimatedInputTokens: 0,
estimatedOutputTokens: 0,
syncCostUsd: 0,
batchCostUsd: 0,
savingsUsd: 0,
pricingSource: "fallback",
warnings: ["Cost estimation failed — shown as $0."],
});
} finally {
setLoading(false);
}
}, [jsonl, model, endpoint]);
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-3">
<span className="material-symbols-outlined text-3xl text-[var(--color-accent)] animate-spin">
progress_activity
</span>
<span className="text-sm text-[var(--color-text-muted)]">Estimating cost</span>
</div>
);
}
return (
<div className="flex flex-col gap-6">
{/* Cost breakdown card */}
{estimate && (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-alt)] divide-y divide-[var(--color-border)]">
{/* Sync cost (baseline) */}
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm text-[var(--color-text-muted)]">{t("wizardCostSync")}</span>
<span className="text-sm text-[var(--color-text-muted)] line-through">
${estimate.syncCostUsd.toFixed(4)}
</span>
</div>
{/* Batch cost (-50%) */}
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-medium text-emerald-400">{t("wizardCostBatch")}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-emerald-500/80 bg-emerald-500/10 rounded px-1.5 py-0.5">
-50%
</span>
<span className="text-sm font-semibold text-emerald-400">
${estimate.batchCostUsd.toFixed(4)}
</span>
</div>
</div>
{/* Savings */}
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm text-[var(--color-text-muted)]">{t("wizardCostSavings")}</span>
<span className="text-sm text-emerald-400">${estimate.savingsUsd.toFixed(4)}</span>
</div>
{/* Stats */}
<div className="flex items-center justify-between px-4 py-3">
<span className="text-xs text-[var(--color-text-muted)]">Requests</span>
<span className="text-xs text-[var(--color-text-muted)]">
{estimate.totalRequests.toLocaleString()} ·{" "}
{estimate.estimatedInputTokens.toLocaleString()} input tok ·{" "}
{estimate.estimatedOutputTokens.toLocaleString()} output tok
</span>
</div>
</div>
)}
{/* Disclaimer */}
<p className="text-xs text-[var(--color-text-muted)] italic">{t("wizardCostEstimatedNotice")}</p>
{/* Warnings */}
{estimate && estimate.warnings.length > 0 && (
<div className="flex flex-col gap-1">
{estimate.warnings.map((w) => (
<div
key={w}
className="rounded-lg border border-yellow-500/25 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-400"
>
{w}
</div>
))}
</div>
)}
{/* Error banner (already sanitized by orchestrator) */}
{error && (
<div
role="alert"
className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-sm text-red-400"
>
{error}
</div>
)}
{/* Create button */}
<button
type="button"
onClick={onCreate}
disabled={creating}
className="w-full rounded-xl py-3 text-sm font-semibold bg-[var(--color-accent)] text-white disabled:opacity-60 hover:opacity-90 transition-opacity flex items-center justify-center gap-2"
>
{creating ? (
<>
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
{t("wizardCreating")}
</>
) : (
<>
<span className="material-symbols-outlined text-sm">rocket_launch</span>
{t("wizardCreate")}
</>
)}
</button>
</div>
);
}

View File

@@ -0,0 +1,211 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { csvToJsonl } from "@/lib/batches/csvToJsonl";
import type { WizardCsvMapping, WizardDestination } from "@/lib/batches/types";
// RFC 4180 minimal CSV row parser (inline — avoids coupling to csvToJsonl internals)
function parseCsvRow(line: string): string[] {
const out: string[] = [];
let buf = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"' && line[i + 1] === '"') {
buf += '"';
i++;
continue;
}
if (ch === '"') {
inQuotes = false;
continue;
}
buf += ch;
} else {
if (ch === '"') {
inQuotes = true;
continue;
}
if (ch === ",") {
out.push(buf);
buf = "";
continue;
}
buf += ch;
}
}
out.push(buf);
return out;
}
const MAPPING_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "", label: "— ignore —" },
{ value: "custom_id", label: "custom_id" },
{ value: "body.messages[0].content", label: "body.messages[0].content" },
{ value: "body.messages[0].role", label: "body.messages[0].role" },
{ value: "body.input", label: "body.input" },
{ value: "body.prompt", label: "body.prompt" },
{ value: "body.max_tokens", label: "body.max_tokens" },
{ value: "body.temperature", label: "body.temperature" },
];
interface ConversionResult {
rowsParsed: number;
rowsSkipped: number;
errors: Array<{ row: number; reason: string }>;
}
interface CsvMappingStepProps {
csvContent: string;
mapping: WizardCsvMapping;
onChange: (mapping: WizardCsvMapping) => void;
destination: WizardDestination | null;
onJsonlReady: (jsonl: string, rowsParsed: number, rowsSkipped: number) => void;
}
export default function CsvMappingStep({
csvContent,
mapping,
onChange,
destination,
onJsonlReady,
}: CsvMappingStepProps) {
const t = useTranslations("common");
const [conversionResult, setConversionResult] = useState<ConversionResult | null>(null);
// Detect columns from the first CSV line
const firstLine = csvContent.split(/\r?\n/)[0] ?? "";
const columns = parseCsvRow(firstLine);
const mappingValues = Object.values(mapping);
const hasCustomId = mappingValues.includes("custom_id");
const hasContent =
mappingValues.some((v) => v.startsWith("body.messages[")) ||
mappingValues.includes("body.input") ||
mappingValues.includes("body.prompt");
const isValid = hasCustomId && hasContent;
function handleColumnMap(column: string, fieldPath: string) {
const next = { ...mapping };
if (!fieldPath) {
delete next[column];
} else {
next[column] = fieldPath;
}
onChange(next);
}
function handleApply() {
if (!isValid || !destination) return;
let result: ReturnType<typeof csvToJsonl>;
try {
result = csvToJsonl({
csv: csvContent,
mapping,
defaults: {
method: "POST",
url: destination.endpoint,
model: destination.model,
},
});
} catch (err) {
console.error("[CsvMappingStep] csvToJsonl error:", err);
return;
}
setConversionResult({
rowsParsed: result.rowsParsed,
rowsSkipped: result.rowsSkipped,
errors: result.errors,
});
if (result.jsonl) {
onJsonlReady(result.jsonl, result.rowsParsed, result.rowsSkipped);
}
}
return (
<div className="flex flex-col gap-4">
<p className="text-sm font-medium text-[var(--color-text)]">{t("wizardCsvMappingTitle")}</p>
{columns.length === 0 && (
<p className="text-xs text-[var(--color-text-muted)]">No columns detected in CSV header.</p>
)}
<div className="flex flex-col gap-3">
{columns.map((col) => (
<div key={col} className="flex items-center gap-3">
<span className="text-xs text-[var(--color-text-muted)] font-mono min-w-[120px] truncate">
{col}
</span>
<span className="text-xs text-[var(--color-text-muted)]"></span>
<select
className="flex-1 rounded border border-[var(--color-border)] bg-[var(--color-bg-alt)] px-2 py-1 text-xs text-[var(--color-text)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)]"
value={mapping[col] ?? ""}
onChange={(e) => handleColumnMap(col, e.target.value)}
>
{MAPPING_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
))}
</div>
{/* Validation hints */}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-xs">
<span
className={`material-symbols-outlined text-sm ${hasCustomId ? "text-emerald-400" : "text-[var(--color-text-muted)]"}`}
>
{hasCustomId ? "check_circle" : "radio_button_unchecked"}
</span>
<span className={hasCustomId ? "text-emerald-400" : "text-[var(--color-text-muted)]"}>
custom_id mapped
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<span
className={`material-symbols-outlined text-sm ${hasContent ? "text-emerald-400" : "text-[var(--color-text-muted)]"}`}
>
{hasContent ? "check_circle" : "radio_button_unchecked"}
</span>
<span className={hasContent ? "text-emerald-400" : "text-[var(--color-text-muted)]"}>
Content field mapped (messages, input, or prompt)
</span>
</div>
</div>
{/* Apply button */}
<button
type="button"
disabled={!isValid || !destination}
onClick={handleApply}
className="self-start rounded-lg px-4 py-2 text-sm font-medium bg-[var(--color-accent)] text-white disabled:opacity-40 hover:opacity-90 transition-opacity"
>
Apply mapping
</button>
{/* Conversion feedback */}
{conversionResult && (
<div className="flex flex-col gap-1 text-xs">
<span className="text-emerald-400">{conversionResult.rowsParsed} rows parsed</span>
{conversionResult.rowsSkipped > 0 && (
<span className="text-yellow-400">{conversionResult.rowsSkipped} rows skipped</span>
)}
{conversionResult.errors.slice(0, 5).map((e) => (
<span key={e.row} className="text-red-400">
Row {e.row}: {e.reason}
</span>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,161 @@
"use client";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { BATCH_SUPPORTED_PROVIDERS, SUPPORTED_BATCH_ENDPOINTS } from "@/lib/batches/types";
import type { WizardDestination } from "@/lib/batches/types";
interface AvailableProvider {
id: string;
name: string;
models: string[];
}
interface DestinationStepProps {
destination: WizardDestination | null;
onChange: (destination: WizardDestination | null) => void;
availableProviders: AvailableProvider[];
}
export default function DestinationStep({
destination,
onChange,
availableProviders,
}: DestinationStepProps) {
const t = useTranslations("common");
// Filter providers to only those with batch support (D16)
const batchProviders = availableProviders.filter((p) =>
BATCH_SUPPORTED_PROVIDERS.includes(p.id as (typeof BATCH_SUPPORTED_PROVIDERS)[number])
);
if (batchProviders.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-4 text-center">
<span className="material-symbols-outlined text-4xl text-[var(--color-text-muted)]">
cloud_off
</span>
<p className="text-sm text-[var(--color-text-muted)] max-w-sm">
{t("wizardEmptyProviders")}
</p>
<Link
href="/dashboard/providers"
className="text-sm text-[var(--color-accent)] underline underline-offset-2 hover:opacity-80"
>
Connect a provider
</Link>
</div>
);
}
const selectedProvider = destination?.provider ?? "";
const selectedEndpoint = destination?.endpoint ?? "/v1/chat/completions";
const selectedModel = destination?.model ?? "";
const providerModels =
batchProviders.find((p) => p.id === selectedProvider)?.models ?? [];
function handleProviderChange(providerId: string) {
if (!providerId) {
onChange(null);
return;
}
// Validate provider type
const validProvider = BATCH_SUPPORTED_PROVIDERS.includes(
providerId as (typeof BATCH_SUPPORTED_PROVIDERS)[number]
);
if (!validProvider) return;
const provider = providerId as WizardDestination["provider"];
const models = batchProviders.find((p) => p.id === provider)?.models ?? [];
onChange({
provider,
endpoint: selectedEndpoint,
model: models[0] ?? "",
});
}
function handleEndpointChange(endpoint: string) {
if (!selectedProvider) return;
onChange({
provider: selectedProvider as WizardDestination["provider"],
endpoint: endpoint as WizardDestination["endpoint"],
model: selectedModel,
});
}
function handleModelChange(model: string) {
if (!selectedProvider) return;
onChange({
provider: selectedProvider as WizardDestination["provider"],
endpoint: selectedEndpoint,
model,
});
}
const selectClass =
"w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-alt)] " +
"px-3 py-2 text-sm text-[var(--color-text)] focus:outline-none " +
"focus:ring-1 focus:ring-[var(--color-accent)] disabled:opacity-50";
return (
<div className="flex flex-col gap-6">
{/* Provider */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[var(--color-text-muted)]">
{t("wizardProviderLabel")}
</label>
<select
className={selectClass}
value={selectedProvider}
onChange={(e) => handleProviderChange(e.target.value)}
>
<option value="">Select a provider</option>
{batchProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
{/* Endpoint */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[var(--color-text-muted)]">
{t("wizardEndpointLabel")}
</label>
<select
className={selectClass}
value={selectedEndpoint}
disabled={!selectedProvider}
onChange={(e) => handleEndpointChange(e.target.value)}
>
{SUPPORTED_BATCH_ENDPOINTS.map((ep) => (
<option key={ep} value={ep}>
{ep}
</option>
))}
</select>
</div>
{/* Model */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[var(--color-text-muted)]">
{t("wizardModelLabel")}
</label>
<select
className={selectClass}
value={selectedModel}
disabled={!selectedProvider || providerModels.length === 0}
onChange={(e) => handleModelChange(e.target.value)}
>
<option value="">Select a model</option>
{providerModels.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
"use client";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import CsvMappingStep from "./CsvMappingStep";
import type { WizardInput, WizardCsvMapping, WizardDestination } from "@/lib/batches/types";
const MAX_FULL_BYTES = 5_000_000; // 5 MB — full read threshold (D7)
const SAMPLE_HEAD_BYTES = 5_000_000;
const SAMPLE_TAIL_BYTES = 100_000;
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
async function readFileContent(file: File): Promise<string> {
if (file.size <= MAX_FULL_BYTES) {
return await file.text();
}
// Sampling for large files (D7)
const headText = await file.slice(0, SAMPLE_HEAD_BYTES).text();
const tailText = await file.slice(file.size - SAMPLE_TAIL_BYTES).text();
return headText + "\n[...sample only...]\n" + tailText;
}
interface InputStepProps {
input: WizardInput;
onChange: (input: WizardInput) => void;
destination: WizardDestination | null;
}
export default function InputStep({ input, onChange, destination }: InputStepProps) {
const t = useTranslations("common");
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [isReading, setIsReading] = useState(false);
const [csvJsonl, setCsvJsonl] = useState<string | null>(null);
const isJsonl = input.kind === "jsonl";
const isCsv = input.kind === "csv";
function handleKindChange(kind: "jsonl" | "csv") {
onChange({ kind, fileName: null, rawContent: null, csvMapping: kind === "csv" ? {} : undefined });
setCsvJsonl(null);
}
async function processFile(file: File) {
const expectedExt = isJsonl ? ".jsonl" : ".csv";
if (!file.name.toLowerCase().endsWith(expectedExt)) {
// Soft warning — don't block, let validation catch it
}
setIsReading(true);
try {
const content = await readFileContent(file);
onChange({
kind: input.kind,
fileName: file.name,
rawContent: content,
csvMapping: input.kind === "csv" ? (input.csvMapping ?? {}) : undefined,
});
} catch (err) {
console.error("[InputStep] file read error:", err);
} finally {
setIsReading(false);
}
}
function handleFileInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) processFile(file);
// Reset input so same file can be re-picked
e.target.value = "";
}
function handleDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) processFile(file);
}
function handleDragOver(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setIsDragging(true);
}
function handleDragLeave() {
setIsDragging(false);
}
function handleCsvMappingChange(mapping: WizardCsvMapping) {
onChange({ ...input, csvMapping: mapping });
setCsvJsonl(null);
}
function handleJsonlReady(jsonl: string, _rowsParsed: number, _rowsSkipped: number) {
setCsvJsonl(jsonl);
// Replace rawContent with the converted JSONL so validation step gets it
onChange({ ...input, rawContent: jsonl, csvMapping: input.csvMapping });
}
const isLargeFile = input.rawContent != null && input.rawContent.includes("[...sample only...]");
const hasFile = input.fileName != null && input.rawContent != null;
const csvMappingReady = isCsv && csvJsonl != null;
// For CSV: needs mapping complete + jsonl generated to be "ready"
// rawContent will be updated by handleJsonlReady once mapping applied
const acceptAttr = isJsonl ? ".jsonl" : ".csv";
return (
<div className="flex flex-col gap-6">
{/* Kind toggle */}
<div className="flex rounded-lg border border-[var(--color-border)] overflow-hidden self-start">
<button
type="button"
onClick={() => handleKindChange("jsonl")}
className={`px-4 py-2 text-sm font-medium transition-colors ${isJsonl ? "bg-[var(--color-accent)] text-white" : "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"}`}
>
{t("wizardInputKindJsonl")}
</button>
<button
type="button"
onClick={() => handleKindChange("csv")}
className={`px-4 py-2 text-sm font-medium transition-colors ${isCsv ? "bg-[var(--color-accent)] text-white" : "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"}`}
>
{t("wizardInputKindCsv")}
</button>
</div>
{/* Drop zone */}
<div
role="button"
tabIndex={0}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => fileInputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click();
}}
className={`rounded-xl border-2 border-dashed p-8 flex flex-col items-center gap-3 cursor-pointer transition-colors
${isDragging ? "border-[var(--color-accent)] bg-[var(--color-accent)]/5" : "border-[var(--color-border)] hover:border-[var(--color-accent)]/50"}`}
>
<span className="material-symbols-outlined text-3xl text-[var(--color-text-muted)]">
upload_file
</span>
{isReading ? (
<span className="text-sm text-[var(--color-text-muted)]">Reading file</span>
) : hasFile ? (
<div className="flex flex-col items-center gap-1">
<span className="text-sm text-[var(--color-text)] font-medium">{input.fileName}</span>
<span className="text-xs text-[var(--color-text-muted)]">
{isLargeFile ? "Large file — validation by sampling" : "Ready"}
</span>
</div>
) : (
<span className="text-sm text-[var(--color-text-muted)]">{t("wizardDropOrPick")}</span>
)}
<input
ref={fileInputRef}
type="file"
accept={acceptAttr}
className="hidden"
onChange={handleFileInputChange}
/>
</div>
{/* Large file warning */}
{isLargeFile && (
<div className="rounded-lg border border-yellow-500/25 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-400">
Large file detected validation will run by sampling (first 5 MB + last 100 KB). Full
validation happens server-side.
</div>
)}
{/* CSV mapping (inline step 2.5) */}
{isCsv && hasFile && input.rawContent && (
<div className="rounded-xl border border-[var(--color-border)] p-4">
<CsvMappingStep
csvContent={input.rawContent}
mapping={input.csvMapping ?? {}}
onChange={handleCsvMappingChange}
destination={destination}
onJsonlReady={handleJsonlReady}
/>
{csvMappingReady && (
<p className="mt-3 text-xs text-emerald-400">JSONL generated ready to validate.</p>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { validateJsonl } from "@/lib/batches/validateJsonl";
import type { ValidationResult } from "@/lib/batches/types";
import type { SupportedBatchEndpoint } from "@/lib/batches/types";
interface JsonlValidationStepProps {
jsonl: string;
endpoint: SupportedBatchEndpoint;
onResult: (result: ValidationResult) => void;
}
export default function JsonlValidationStep({
jsonl,
endpoint,
onResult,
}: JsonlValidationStepProps) {
const t = useTranslations("common");
const [result, setResult] = useState<ValidationResult | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
setResult(null);
try {
const r = validateJsonl(jsonl, { endpoint });
setResult(r);
onResult(r);
} catch (err) {
console.error("[JsonlValidationStep] validate error:", err);
// Provide a minimal failed result on exception
const errResult: ValidationResult = {
ok: false,
totalLines: 0,
sampledLines: 0,
uniqueCustomIds: 0,
duplicateCustomIds: [],
errors: [{ lineNumber: 0, reason: "Validation failed — could not parse content." }],
preview: [],
byteSize: 0,
};
setResult(errResult);
onResult(errResult);
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jsonl, endpoint]);
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-3">
<span className="material-symbols-outlined text-3xl text-[var(--color-accent)] animate-spin">
progress_activity
</span>
<span className="text-sm text-[var(--color-text-muted)]">Validating</span>
</div>
);
}
if (!result) return null;
return (
<div className="flex flex-col gap-5">
{/* OK / Error banner */}
{result.ok ? (
<div className="flex items-center gap-3 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-4 py-3">
<span className="material-symbols-outlined text-emerald-400">check_circle</span>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-emerald-400">{t("wizardValidationOk")}</span>
<span className="text-xs text-[var(--color-text-muted)]">
{result.totalLines} lines · {result.uniqueCustomIds} unique custom_ids
</span>
</div>
</div>
) : (
<div className="flex items-center gap-3 rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3">
<span className="material-symbols-outlined text-red-400">error</span>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-red-400">{t("wizardValidationErrors")}</span>
<span className="text-xs text-[var(--color-text-muted)]">
{result.errors.length} error{result.errors.length !== 1 ? "s" : ""} found
</span>
</div>
</div>
)}
{/* Sampling note */}
{result.sampledLines < result.totalLines && (
<div className="rounded-lg border border-yellow-500/25 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-400">
{t("wizardValidationSamplingNote")}
</div>
)}
{/* Duplicate IDs */}
{result.duplicateCustomIds.length > 0 && (
<div className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 flex flex-col gap-1">
<span className="text-xs font-medium text-red-400">Duplicate custom_ids detected:</span>
{result.duplicateCustomIds.slice(0, 10).map((id) => (
<span key={id} className="text-xs text-red-300 font-mono">
{id}
</span>
))}
</div>
)}
{/* Error table */}
{result.errors.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-[var(--color-text-muted)]">
Errors (first {Math.min(result.errors.length, 50)}):
</span>
<div className="max-h-60 overflow-auto rounded-lg border border-[var(--color-border)] divide-y divide-[var(--color-border)]">
{result.errors.slice(0, 50).map((err) => (
<div key={`${err.lineNumber}-${err.reason}`} className="px-3 py-2 flex gap-3 text-xs">
<span className="text-[var(--color-text-muted)] min-w-[60px]">
Line {err.lineNumber}
</span>
<span className="text-red-400 flex-1">{err.reason}</span>
{err.field && (
<span className="text-[var(--color-text-muted)] font-mono">{err.field}</span>
)}
</div>
))}
</div>
</div>
)}
{/* Preview */}
{result.preview.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-[var(--color-text-muted)]">
{t("wizardValidationPreview")}
</span>
<pre className="max-h-40 overflow-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-alt)] p-3 text-xs text-[var(--color-text)] whitespace-pre-wrap break-all">
{JSON.stringify(result.preview, null, 2)}
</pre>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,517 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── i18n mock ─────────────────────────────────────────────────────────────────
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// ── next/link mock ────────────────────────────────────────────────────────────
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));
// ── lib mocks ─────────────────────────────────────────────────────────────────
vi.mock("@/lib/batches/validateJsonl", () => ({
validateJsonl: vi.fn(() => ({
ok: true,
totalLines: 1,
sampledLines: 1,
uniqueCustomIds: 1,
duplicateCustomIds: [],
errors: [],
preview: [{ custom_id: "req-1", method: "POST", url: "/v1/chat/completions", body: {} }],
byteSize: 100,
})),
}));
vi.mock("@/lib/batches/costEstimator", () => ({
estimateBatchCost: vi.fn(() => ({
model: "gpt-4o-mini",
totalRequests: 1,
estimatedInputTokens: 100,
estimatedOutputTokens: 256,
syncCostUsd: 0.001,
batchCostUsd: 0.0005,
savingsUsd: 0.0005,
pricingSource: "exact-match" as const,
warnings: [],
})),
}));
vi.mock("@/lib/batches/csvToJsonl", () => ({
csvToJsonl: vi.fn(() => ({
jsonl:
'{"custom_id":"req-1","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}}\n',
rowsParsed: 1,
rowsSkipped: 0,
errors: [],
})),
}));
// ── Fetch mock ────────────────────────────────────────────────────────────────
const mockFetch = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/v1/files") {
return { ok: true, json: async () => ({ id: "file-test" }) };
}
if (url === "/api/v1/batches") {
return { ok: true, json: async () => ({ id: "batch-test" }) };
}
return { ok: false, status: 404, json: async () => ({}) };
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
// Unmount all containers
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
});
// ── Component import ──────────────────────────────────────────────────────────
const { default: NewBatchWizard } = await import(
"../../../../../src/app/(dashboard)/dashboard/batch/components/NewBatchWizard"
);
// ── Render helpers ────────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
const DEFAULT_PROVIDERS = [
{ id: "openai", name: "OpenAI", models: ["gpt-4o-mini", "gpt-4o"] },
];
function renderWizard(props?: {
onClose?: () => void;
onCreated?: (id: string) => void;
availableProviders?: Array<{ id: string; name: string; models: string[] }>;
}) {
const onClose = props?.onClose ?? vi.fn();
const onCreated = props?.onCreated ?? vi.fn();
const availableProviders = props?.availableProviders ?? DEFAULT_PROVIDERS;
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(
<NewBatchWizard
onClose={onClose}
onCreated={onCreated}
availableProviders={availableProviders}
/>
);
});
containers.push({ root, el });
return { el, onClose, onCreated };
}
// Helper: wait for a condition to be true with retries
async function waitFor(
fn: () => boolean,
options: { timeout?: number; interval?: number } = {}
): Promise<void> {
const { timeout = 3000, interval = 50 } = options;
const start = Date.now();
while (Date.now() - start < timeout) {
if (fn()) return;
await new Promise((r) => setTimeout(r, interval));
}
if (!fn()) throw new Error("waitFor timed out");
}
// Helper: navigate to a given step by filling required fields
async function goToStep2(el: HTMLElement) {
const selects = el.querySelectorAll("select");
await act(async () => {
(selects[0] as HTMLSelectElement).value = "openai";
selects[0].dispatchEvent(new Event("change", { bubbles: true }));
});
// After provider selected, get updated selects
const selectsAfter = el.querySelectorAll("select");
await act(async () => {
(selectsAfter[2] as HTMLSelectElement).value = "gpt-4o-mini";
selectsAfter[2].dispatchEvent(new Event("change", { bubbles: true }));
});
const nextBtn = el.querySelector("button:not([disabled])")!;
// find Next button by text
const allBtns = Array.from(el.querySelectorAll("button"));
const nextBtns = allBtns.filter((b) => b.textContent === "wizardNext");
await act(async () => {
if (nextBtns[0] && !(nextBtns[0] as HTMLButtonElement).disabled) {
nextBtns[0].click();
}
});
}
async function injectFileContent(el: HTMLElement, content: string, filename = "batch.jsonl") {
const fileInput = el.querySelector("input[type='file']") as HTMLInputElement;
const file = new File([content], filename, { type: "application/jsonl" });
await act(async () => {
Object.defineProperty(fileInput, "files", { value: [file], configurable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
});
// File.text() is async — wait a tick
await new Promise((r) => setTimeout(r, 100));
}
const JSONL_VALID =
'{"custom_id":"req-1","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}}\n';
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("NewBatchWizard", () => {
// 1. Renders step 1 with dropdowns, Next disabled
it("renders step 1 with provider/endpoint/model labels and Next disabled", () => {
const { el } = renderWizard();
expect(el.textContent).toContain("wizardStep1Destination");
expect(el.textContent).toContain("wizardProviderLabel");
expect(el.textContent).toContain("wizardEndpointLabel");
expect(el.textContent).toContain("wizardModelLabel");
const nextBtns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
expect(nextBtns.length).toBeGreaterThan(0);
expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(true);
});
// 2. Empty state when availableProviders is empty
it("shows empty state and no selects when no batch-capable providers", () => {
const { el } = renderWizard({ availableProviders: [] });
expect(el.textContent).toContain("wizardEmptyProviders");
expect(el.querySelectorAll("select").length).toBe(0);
});
// 3. Escape key → onClose
it("calls onClose on Escape key press", () => {
const onClose = vi.fn();
renderWizard({ onClose });
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
expect(onClose).toHaveBeenCalledTimes(1);
});
// 4. Cancel button → onClose
it("calls onClose when Cancel button is clicked", () => {
const onClose = vi.fn();
const { el } = renderWizard({ onClose });
const cancelBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "wizardCancel"
);
expect(cancelBtn).toBeDefined();
act(() => {
cancelBtn!.click();
});
expect(onClose).toHaveBeenCalledTimes(1);
});
// 5. Overlay click → onClose
it("calls onClose when overlay backdrop is clicked", () => {
const onClose = vi.fn();
const { el } = renderWizard({ onClose });
// Overlay has the backdrop-blur-sm + bg-black/40 class
const overlay = el.querySelector(".backdrop-blur-sm");
expect(overlay).not.toBeNull();
act(() => {
overlay!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onClose).toHaveBeenCalledTimes(1);
});
// 6. Selecting provider + model enables Next, clicking goes to step 2
it("enables Next after selecting provider+model and advances to step 2", async () => {
const { el } = renderWizard();
const selects = el.querySelectorAll("select");
await act(async () => {
(selects[0] as HTMLSelectElement).value = "openai";
selects[0].dispatchEvent(new Event("change", { bubbles: true }));
});
const selectsAfter = el.querySelectorAll("select");
await act(async () => {
(selectsAfter[2] as HTMLSelectElement).value = "gpt-4o-mini";
selectsAfter[2].dispatchEvent(new Event("change", { bubbles: true }));
});
const nextBtns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(false);
await act(async () => {
nextBtns[0].click();
});
expect(el.textContent).toContain("wizardInputKindJsonl");
expect(el.textContent).toContain("wizardInputKindCsv");
});
// 7. Step 2 — Next disabled without file
it("shows JSONL/CSV toggle in step 2 and Next disabled without file", async () => {
const { el } = renderWizard();
await goToStep2(el);
expect(el.textContent).toContain("wizardInputKindJsonl");
expect(el.textContent).toContain("wizardDropOrPick");
const nextBtns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(true);
});
// 8. Back button returns to step 1
it("shows Back button on step 2 and navigates back to step 1", async () => {
const { el } = renderWizard();
await goToStep2(el);
const backBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "wizardBack"
);
expect(backBtn).toBeDefined();
await act(async () => {
backBtn!.click();
});
expect(el.textContent).toContain("wizardStep1Destination");
const backBtns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardBack"
);
expect(backBtns.length).toBe(0);
});
// 9. Validation errors block Next in step 3
it("blocks Next in step 3 when validation returns errors", async () => {
const { validateJsonl } = await import("@/lib/batches/validateJsonl");
vi.mocked(validateJsonl).mockReturnValueOnce({
ok: false,
totalLines: 2,
sampledLines: 2,
uniqueCustomIds: 0,
duplicateCustomIds: [],
errors: [{ lineNumber: 1, reason: "custom_id missing or empty", field: "custom_id" }],
preview: [],
byteSize: 50,
});
const { el } = renderWizard();
await goToStep2(el);
await injectFileContent(el, JSONL_VALID);
// Wait for Next to enable (file loaded)
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
await act(async () => {
const nextBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "wizardNext"
);
nextBtn!.click();
});
// Wait for validation to complete
await waitFor(() => el.textContent!.includes("wizardValidationErrors"));
// Error text visible
expect(el.textContent).toContain("custom_id missing or empty");
// Next disabled
const nextBtns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(true);
});
// 10. Step 4 cost breakdown visible
it("renders cost breakdown card in step 4 after validation ok", async () => {
const { el } = renderWizard();
await goToStep2(el);
await injectFileContent(el, JSONL_VALID);
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
// Step 3
await act(async () => {
const nextBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "wizardNext"
);
nextBtn!.click();
});
// Wait validation completes
await waitFor(() => el.textContent!.includes("wizardValidationOk"));
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
// Step 4
await act(async () => {
const nextBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "wizardNext"
);
nextBtn!.click();
});
// Wait for cost estimate to load
await waitFor(() => el.textContent!.includes("wizardCostSync"), { timeout: 5000 });
expect(el.textContent).toContain("wizardCostBatch");
expect(el.textContent).toContain("wizardCostSavings");
expect(el.textContent).toContain("wizardCreate");
});
// 11. Full happy path → onCreated("batch-test")
it("calls onCreated('batch-test') after complete wizard flow", async () => {
const onCreated = vi.fn();
const onClose = vi.fn();
const { el } = renderWizard({ onCreated, onClose });
await goToStep2(el);
await injectFileContent(el, JSONL_VALID);
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
// Step 3
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent === "wizardNext")!
.click();
});
await waitFor(() => el.textContent!.includes("wizardValidationOk"));
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
// Step 4
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent === "wizardNext")!
.click();
});
await waitFor(() => el.textContent!.includes("wizardCreate"), { timeout: 5000 });
// Click create (textContent includes icon text "rocket_launch" before the key)
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent?.includes("wizardCreate"))!
.click();
});
await waitFor(() => onCreated.mock.calls.length > 0, { timeout: 5000 });
expect(onCreated).toHaveBeenCalledWith("batch-test");
expect(onClose).toHaveBeenCalled();
// Assert fetch shapes
expect(mockFetch).toHaveBeenCalledWith("/api/v1/files", expect.objectContaining({ method: "POST" }));
const batchCall = mockFetch.mock.calls.find((c) => c[0] === "/api/v1/batches");
expect(batchCall).toBeDefined();
const body = JSON.parse(batchCall![1].body as string) as Record<string, unknown>;
expect(body.input_file_id).toBe("file-test");
expect(body.completion_window).toBe("24h");
expect(typeof body.endpoint).toBe("string");
});
// 12. File upload 500 → error banner sanitized (no stack trace / path)
it("shows sanitized error on file upload 500 — no stack trace in banner", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/v1/files") {
return {
ok: false,
status: 500,
json: async () => ({
error: { message: "Internal error at /home/user/server/files.ts:42 — stack at line 42" },
}),
};
}
return { ok: false, status: 404, json: async () => ({}) };
});
const onCreated = vi.fn();
const onClose = vi.fn();
const { el } = renderWizard({ onCreated, onClose });
await goToStep2(el);
await injectFileContent(el, JSONL_VALID);
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent === "wizardNext")!
.click();
});
await waitFor(() => el.textContent!.includes("wizardValidationOk"));
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent === "wizardNext")!
.click();
});
await waitFor(() => el.textContent!.includes("wizardCreate"), { timeout: 5000 });
// Click create (textContent includes icon text "rocket_launch" before the key)
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent?.includes("wizardCreate"))!
.click();
});
// Wait for error to appear
await waitFor(() => el.querySelector("[role='alert']") !== null, { timeout: 5000 });
const banner = el.querySelector("[role='alert']")!;
const bannerText = banner.textContent ?? "";
// SANITIZATION ASSERT — hard requirement from D14 / Hard Rule #12
expect(bannerText).not.toMatch(/\/home\/|stack at|at \//);
// Should show the i18n error key (mocked to return the key string)
expect(bannerText).toBe("wizardErrorUpload");
// onCreated must NOT have been called
expect(onCreated).not.toHaveBeenCalled();
});
});