mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Merge F4 (NewBatchWizard 4-step modal + tests) into orchestrator branch
# Conflicts: # src/app/(dashboard)/dashboard/batch/components/NewBatchWizard.tsx
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
517
tests/unit/dashboard/batch/components/NewBatchWizard.test.tsx
Normal file
517
tests/unit/dashboard/batch/components/NewBatchWizard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user