mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-09 17:28:51 +00:00
13 fixes from independent round-2 code review:
- B-1: Fix FileRecord wrong import in batch-utils.ts (TS2305 masked by limited typecheck-core scope); include batch-utils.ts + 5 lib/batches/* in tsconfig.typecheck-core.json so future regressions are caught
- A-2: role="dialog" + aria-modal + aria-labelledby on NewBatchWizard + BatchDetailModal panels (a11y consistency with UploadFileModal)
- A-3: "Janela de conclusão de 24h" line in CostEstimateStep (spec §5 explicit)
- A-7: "campos obrigatórios válidos" appended to JsonlValidationStep success summary (spec §5)
- A-9 + B-7: 18 hardcoded UI strings → i18n keys (Size, Refresh/Refreshing, Remove, Uploading, Reading file, Ready, Large file warning, JSONL generated, etc.)
- B-4: Strip UTF-8 BOM in validateJsonl + csvToJsonl (Windows-saved files no longer fail "invalid JSON" cryptically)
- B-5: Remove dead exports WizardStep + BatchProviderConfig from types.ts
- A-1: Add Provider column with derived heuristic (gpt-/o1-/o3-/text-embedding-/dall-e- → OpenAI; claude- → Anthropic; gemini → Gemini; else "—") in BatchListTab (BatchRecord has no provider field, so derivation is display-only)
- A-5: Enter key advances wizard step via data-wizard-next button trigger (skip when focus in INPUT/TEXTAREA/SELECT)
- A-6: Auto-dismiss "Batch {id} criado" banner on /batch page after wizard completes; consumes onCreated id (was discarded as _id)
- B-2: Race guard in InputStep.processFile (early-return if isReading) + drop zone pointer-events-none while reading
- C-tests: 6 new regression tests covering body=[] Array.isArray guard, BOM stripping, alias-match pricing path, (partial) suffix on expired_with_failures, -50% inline badge on Cost column, Provider column derivation per family
Side fix: narrow Record<string,unknown> child navigation in csvToJsonl.ts:135 to silence TS2322 once file is in typecheck scope.
22 new i18n keys (filesListSizeColumn, batchListProviderColumn/Unknown/BatchCreated/Dismiss/Refreshing/Refresh, uploadFileModalRemove/Uploading, wizardInputReading/Ready/LargeFileLabel/CsvJsonlReady/LargeFileWarning, wizardCostWindow24h, wizardValidationFieldsOk) added in en.json + pt-BR.json and propagated to 40 locales via fill-missing-from-en.mjs.
216 lines
10 KiB
TypeScript
216 lines
10 KiB
TypeScript
import { test } from "node:test";
|
||
import assert from "node:assert/strict";
|
||
|
||
const { validateJsonl } = await import("../../../../src/lib/batches/validateJsonl.ts");
|
||
|
||
const ENDPOINT = "/v1/chat/completions" as const;
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function makeLine(
|
||
customId: string,
|
||
url: string = ENDPOINT,
|
||
method: string = "POST",
|
||
body: unknown = { model: "gpt-4o", messages: [{ role: "user", content: "hi" }] }
|
||
) {
|
||
return JSON.stringify({ custom_id: customId, method, url, body });
|
||
}
|
||
|
||
function makeJsonl(lines: string[]) {
|
||
return lines.join("\n") + "\n";
|
||
}
|
||
|
||
// ── Empty / trivial ────────────────────────────────────────────────────────────
|
||
|
||
test("validateJsonl: empty string → ok=false, totalLines=0", () => {
|
||
const result = validateJsonl("", { endpoint: ENDPOINT });
|
||
assert.equal(result.ok, true, "empty JSONL has no errors — but totalLines=0");
|
||
assert.equal(result.totalLines, 0);
|
||
assert.equal(result.sampledLines, 0);
|
||
assert.equal(result.errors.length, 0);
|
||
});
|
||
|
||
test("validateJsonl: whitespace-only content → no lines", () => {
|
||
const result = validateJsonl(" \n\n \n", { endpoint: ENDPOINT });
|
||
assert.equal(result.totalLines, 0);
|
||
});
|
||
|
||
// ── Valid lines ────────────────────────────────────────────────────────────────
|
||
|
||
test("validateJsonl: 1 valid OpenAI-shape line → ok=true, 1 uniqueCustomId", () => {
|
||
const jsonl = makeJsonl([makeLine("req-1")]);
|
||
const result = validateJsonl(jsonl, { endpoint: ENDPOINT });
|
||
assert.ok(result.ok, "valid line should produce ok=true");
|
||
assert.equal(result.totalLines, 1);
|
||
assert.equal(result.sampledLines, 1);
|
||
assert.equal(result.uniqueCustomIds, 1);
|
||
assert.equal(result.errors.length, 0);
|
||
assert.equal(result.duplicateCustomIds.length, 0);
|
||
assert.equal(result.preview.length, 1);
|
||
});
|
||
|
||
test("validateJsonl: Anthropic params shape → ok=true (no url/method/body required)", () => {
|
||
const line = JSON.stringify({
|
||
custom_id: "anthropic-req-1",
|
||
params: { model: "claude-3-5-sonnet-20241022", messages: [{ role: "user", content: "hi" }] },
|
||
});
|
||
const result = validateJsonl(line + "\n", { endpoint: ENDPOINT });
|
||
assert.ok(result.ok);
|
||
assert.equal(result.uniqueCustomIds, 1);
|
||
});
|
||
|
||
test("validateJsonl: multiple valid lines → uniqueCustomIds matches count", () => {
|
||
const lines = Array.from({ length: 5 }, (_, i) => makeLine(`req-${i + 1}`));
|
||
const result = validateJsonl(makeJsonl(lines), { endpoint: ENDPOINT });
|
||
assert.ok(result.ok);
|
||
assert.equal(result.uniqueCustomIds, 5);
|
||
assert.equal(result.totalLines, 5);
|
||
});
|
||
|
||
// ── Errors: invalid JSON ───────────────────────────────────────────────────────
|
||
|
||
test("validateJsonl: invalid JSON line → error with 'invalid JSON' reason", () => {
|
||
const jsonl = "this is not json\n";
|
||
const result = validateJsonl(jsonl, { endpoint: ENDPOINT });
|
||
assert.equal(result.ok, false);
|
||
assert.ok(result.errors.some((e) => e.reason.toLowerCase().includes("invalid json")));
|
||
});
|
||
|
||
// ── Errors: missing custom_id ─────────────────────────────────────────────────
|
||
|
||
test("validateJsonl: missing custom_id → error reported with field=custom_id", () => {
|
||
const line = JSON.stringify({ method: "POST", url: ENDPOINT, body: {} });
|
||
const result = validateJsonl(line + "\n", { endpoint: ENDPOINT });
|
||
assert.equal(result.ok, false);
|
||
const err = result.errors.find((e) => e.field === "custom_id");
|
||
assert.ok(err, "should have a custom_id error");
|
||
});
|
||
|
||
test("validateJsonl: empty custom_id string → error", () => {
|
||
const line = JSON.stringify({ custom_id: "", method: "POST", url: ENDPOINT, body: {} });
|
||
const result = validateJsonl(line + "\n", { endpoint: ENDPOINT });
|
||
assert.equal(result.ok, false);
|
||
assert.ok(result.errors.some((e) => e.field === "custom_id"));
|
||
});
|
||
|
||
// ── Errors: duplicate custom_id ───────────────────────────────────────────────
|
||
|
||
test("validateJsonl: duplicate custom_id → ok=false, duplicateCustomIds populated", () => {
|
||
const lines = [makeLine("dup-id"), makeLine("dup-id"), makeLine("unique-id")];
|
||
const result = validateJsonl(makeJsonl(lines), { endpoint: ENDPOINT });
|
||
assert.equal(result.ok, false);
|
||
assert.ok(result.duplicateCustomIds.includes("dup-id"));
|
||
assert.equal(result.uniqueCustomIds, 2, "dup-id + unique-id = 2 unique ids");
|
||
});
|
||
|
||
// ── Errors: wrong url / method ─────────────────────────────────────────────────
|
||
|
||
test("validateJsonl: url differs from endpoint → error on field=url", () => {
|
||
const line = JSON.stringify({
|
||
custom_id: "req-1",
|
||
method: "POST",
|
||
url: "/v1/embeddings",
|
||
body: {},
|
||
});
|
||
const result = validateJsonl(line + "\n", { endpoint: ENDPOINT });
|
||
assert.equal(result.ok, false);
|
||
assert.ok(result.errors.some((e) => e.field === "url"));
|
||
});
|
||
|
||
test("validateJsonl: completely unsupported url → error on field=url", () => {
|
||
const line = JSON.stringify({ custom_id: "req-1", method: "POST", url: "/v1/unknown", body: {} });
|
||
const result = validateJsonl(line + "\n", { endpoint: ENDPOINT });
|
||
assert.equal(result.ok, false);
|
||
assert.ok(result.errors.some((e) => e.field === "url"));
|
||
});
|
||
|
||
test("validateJsonl: method is GET instead of POST → error on field=method", () => {
|
||
const line = JSON.stringify({ custom_id: "req-1", method: "GET", url: ENDPOINT, body: {} });
|
||
const result = validateJsonl(line + "\n", { endpoint: ENDPOINT });
|
||
assert.equal(result.ok, false);
|
||
assert.ok(result.errors.some((e) => e.field === "method"));
|
||
});
|
||
|
||
// ── Sampling ──────────────────────────────────────────────────────────────────
|
||
|
||
test("validateJsonl: sampling — 1500 lines with maxLinesToInspect=1000, tailLinesToInspect=100", () => {
|
||
const lines = Array.from({ length: 1500 }, (_, i) => makeLine(`req-${i}`));
|
||
const result = validateJsonl(makeJsonl(lines), {
|
||
endpoint: ENDPOINT,
|
||
maxLinesToInspect: 1000,
|
||
tailLinesToInspect: 100,
|
||
});
|
||
assert.equal(result.totalLines, 1500);
|
||
// sampledLines = head 1000 + tail 100 (but head already covers 1000..1399, so tail adds 100 extra beyond 1000)
|
||
assert.ok(result.sampledLines >= 1000, "should have sampled at least 1000 lines");
|
||
assert.ok(result.sampledLines <= 1100, "should not exceed head+tail");
|
||
});
|
||
|
||
test("validateJsonl: no sampling — all lines inspected when maxLinesToInspect >= totalLines", () => {
|
||
const lines = Array.from({ length: 10 }, (_, i) => makeLine(`req-${i}`));
|
||
const result = validateJsonl(makeJsonl(lines), {
|
||
endpoint: ENDPOINT,
|
||
maxLinesToInspect: 10000,
|
||
});
|
||
assert.equal(result.sampledLines, 10);
|
||
assert.equal(result.totalLines, 10);
|
||
});
|
||
|
||
// ── Preview ────────────────────────────────────────────────────────────────────
|
||
|
||
test("validateJsonl: preview contains at most 5 items", () => {
|
||
const lines = Array.from({ length: 10 }, (_, i) => makeLine(`req-${i}`));
|
||
const result = validateJsonl(makeJsonl(lines), { endpoint: ENDPOINT });
|
||
assert.ok(result.preview.length <= 5, `preview should be ≤5, got ${result.preview.length}`);
|
||
});
|
||
|
||
test("validateJsonl: invalid lines are not included in preview", () => {
|
||
const jsonl = "not-json\n" + makeLine("req-1") + "\n";
|
||
const result = validateJsonl(jsonl, { endpoint: ENDPOINT });
|
||
// preview should only contain the valid line
|
||
assert.ok(result.preview.length <= 1);
|
||
});
|
||
|
||
// ── byteSize ──────────────────────────────────────────────────────────────────
|
||
|
||
test("validateJsonl: byteSize matches UTF-8 byte length of input", () => {
|
||
const content = makeJsonl([makeLine("req-1")]);
|
||
const result = validateJsonl(content, { endpoint: ENDPOINT });
|
||
const expected = new TextEncoder().encode(content).length;
|
||
assert.equal(result.byteSize, expected);
|
||
});
|
||
|
||
// ── Errors cap ────────────────────────────────────────────────────────────────
|
||
|
||
test("validateJsonl: errors capped at 50 even with many invalid lines", () => {
|
||
const lines = Array.from({ length: 100 }, (_, i) => `{"custom_id":"req-${i}","method":"GET","url":"${ENDPOINT}","body":{}}`);
|
||
const result = validateJsonl(makeJsonl(lines), { endpoint: ENDPOINT });
|
||
assert.ok(result.errors.length <= 50, `errors should be capped at 50, got ${result.errors.length}`);
|
||
});
|
||
|
||
// ── body must be object (not array) — Array.isArray guard ─────────────────────
|
||
|
||
test("validateJsonl: rejects body that is an array (typeof []==='object' guard)", () => {
|
||
const line = JSON.stringify({ custom_id: "req-1", method: "POST", url: ENDPOINT, body: [] });
|
||
const result = validateJsonl(line, { endpoint: ENDPOINT });
|
||
assert.ok(!result.ok, "should be invalid");
|
||
assert.ok(
|
||
result.errors.some((e) => e.field === "body"),
|
||
`should flag body field; errors=${JSON.stringify(result.errors)}`,
|
||
);
|
||
});
|
||
|
||
// ── BOM stripping (Windows-saved files) ───────────────────────────────────────
|
||
|
||
test("validateJsonl: strips UTF-8 BOM before parsing first line", () => {
|
||
const line = JSON.stringify({
|
||
custom_id: "req-1",
|
||
method: "POST",
|
||
url: ENDPOINT,
|
||
body: { model: "gpt-4o", messages: [{ role: "user", content: "hi" }] },
|
||
});
|
||
const result = validateJsonl("" + line, { endpoint: ENDPOINT });
|
||
assert.ok(result.ok, `BOM should be stripped; got errors=${JSON.stringify(result.errors)}`);
|
||
assert.equal(result.totalLines, 1);
|
||
assert.equal(result.uniqueCustomIds, 1);
|
||
});
|