From c2d2b0ac1454b984c369ca4396608e77cb0aebde Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Thu, 3 Sep 2026 11:39:03 -0400 Subject: [PATCH] feat(providers): surface CSV import row errors and ship a template (#12504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca. O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. --- .../features/12071-csv-import-errors.md | 1 + docs/guides/USER_GUIDE.md | 2 + docs/providers/CSV-IMPORT.md | 43 +++++ docs/providers/meta.json | 3 +- .../ImportProvidersFromFileModal.tsx | 40 ++++- .../components/providerImportFeedback.ts | 165 ++++++++++++++++++ .../components/useImportProvidersFromFile.ts | 28 +-- src/i18n/messages/en.json | 3 + src/i18n/messages/pt-BR.json | 3 + src/i18n/messages/vi.json | 3 + .../provider-import-feedback-12071.test.ts | 143 +++++++++++++++ 11 files changed, 418 insertions(+), 16 deletions(-) create mode 100644 changelog.d/features/12071-csv-import-errors.md create mode 100644 docs/providers/CSV-IMPORT.md create mode 100644 src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts create mode 100644 tests/unit/provider-import-feedback-12071.test.ts diff --git a/changelog.d/features/12071-csv-import-errors.md b/changelog.d/features/12071-csv-import-errors.md new file mode 100644 index 0000000000..096eed4ef6 --- /dev/null +++ b/changelog.d/features/12071-csv-import-errors.md @@ -0,0 +1 @@ +- **feat(providers):** import-from-file modal shows per-row API errors and ships a downloadable CSV template ([#12071](https://github.com/diegosouzapw/OmniRoute/issues/12071)) diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 72acc05bfc..ceed44c426 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -122,6 +122,8 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... ## 📖 Provider Setup +To bulk-add API-key connections from a CSV or JSON file, use **Dashboard → Providers → Import from file**. Columns are positional (`provider,name,apiKey,baseUrl,priority`); `provider` must already exist as a managed provider or a compatible node. See [Import providers from a CSV or JSON file](../providers/CSV-IMPORT.md). + ### 🔐 Subscription Providers #### Claude Code (Pro/Max) diff --git a/docs/providers/CSV-IMPORT.md b/docs/providers/CSV-IMPORT.md new file mode 100644 index 0000000000..4f701a16a2 --- /dev/null +++ b/docs/providers/CSV-IMPORT.md @@ -0,0 +1,43 @@ +--- +title: "Import providers from a CSV or JSON file" +--- + +# Import providers from a CSV or JSON file + +Dashboard → Providers → **Import from file** creates API-key connections from a CSV or JSON list. Each row can target a different provider. Partial failure is the contract: valid rows still import when others fail, and the modal lists why the failed rows were rejected. + +This import does **not** create new OpenAI/Anthropic-compatible endpoint nodes. Create those first (Dashboard → Providers → Add OpenAI-Compatible, or `omniroute nodes add`), then import rows whose `provider` column is that node's id. A per-row `baseUrl` can still override the node's URL. + +## CSV (positional) + +Column names are cosmetic. The parser splits each row and destructures by index: + +| Index | Field | Required | Notes | +| ----- | ----- | -------- | ----- | +| 0 | `provider` | yes | Existing managed provider id (`openai`, `anthropic`, …) **or** an already-registered OpenAI/Anthropic-compatible **node** id | +| 1 | `name` | yes | Connection display name | +| 2 | `apiKey` | yes | API key | +| 3 | `baseUrl` | no | Per-row URL override | +| 4 | `priority` | no | Integer 1–100 | + +A first line whose first column is the literal word `provider` (any case) is skipped as a header. Blank lines and `#` comments are skipped. + +Download a starter file from the import modal (**Download CSV template**). Example: + +```csv +# OmniRoute provider import (positional columns) +provider,name,apiKey,baseUrl,priority +openai,Prod OpenAI,sk-your-openai-key,,1 +``` + +A made-up id such as `openai-compatible-chat-001` is not a node. The API returns `Unknown or unsupported provider` for that row; the modal shows it next to the row name. + +## JSON + +A JSON array of objects with the same fields (`provider`, `name`, `apiKey`, `baseUrl?`, `priority?`). Unlike CSV, JSON keys are named. + +```json +[ + { "provider": "openai", "name": "Prod OpenAI", "apiKey": "sk-your-openai-key", "priority": 1 } +] +``` diff --git a/docs/providers/meta.json b/docs/providers/meta.json index fa6485dd57..b5eca33685 100644 --- a/docs/providers/meta.json +++ b/docs/providers/meta.json @@ -8,6 +8,7 @@ "AGENTROUTER", "ZED-DOCKER", "CURSOR-DOCKER", - "CURSOR-API-KEY-AND-CLI" + "CURSOR-API-KEY-AND-CLI", + "CSV-IMPORT" ] } diff --git a/src/app/(dashboard)/dashboard/providers/components/ImportProvidersFromFileModal.tsx b/src/app/(dashboard)/dashboard/providers/components/ImportProvidersFromFileModal.tsx index 0363f205d8..b9f9417b50 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ImportProvidersFromFileModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/ImportProvidersFromFileModal.tsx @@ -4,6 +4,12 @@ import { useTranslations } from "next-intl"; import { Button, Modal } from "@/shared/components"; import type { ParsedProviderImportEntry, ProviderImportParseError } from "./parseProviderImportFile"; import { useImportProvidersFromFile } from "./useImportProvidersFromFile"; +import { + downloadProviderImportCsvTemplate, + formatImportErrorLine, + visibleImportErrors, + type ImportResult, +} from "./providerImportFeedback"; interface ImportProvidersFromFileModalProps { isOpen: boolean; @@ -122,6 +128,30 @@ function FilePickerRow({ fileInputRef, fileName, onFile, t }: FilePickerRowProps ); } +function ImportResultPanel({ result, t }: { result: ImportResult; t: Translator }) { + const { shown, extra } = visibleImportErrors(result.errors); + const failed = result.failed > 0 || shown.length > 0; + return ( +
+ {t("importFromFileResult", { success: result.success, failed: result.failed })} + {shown.length > 0 && ( + + )} +
+ ); +} + /** * Wizard step: upload a CSV/JSON file listing MULTIPLE, possibly different providers, * pick which parsed rows to actually import, then submit them in one batch (#6836). @@ -141,18 +171,18 @@ export function ImportProvidersFromFileModal({ s.handleClose(onClose)} title={t("importFromFileTitle")} maxWidth="xl">

{t("importFromFileDescription")}

+

{t("importFromFileSchemaHint")}

- {s.result && ( -
- {t("importFromFileResult", { success: s.result.success, failed: s.result.failed })} -
- )} + {s.result && }
+ diff --git a/src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts b/src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts new file mode 100644 index 0000000000..4f1564051a --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts @@ -0,0 +1,165 @@ +/** + * #12071 — import-modal feedback helpers. + * + * POST /api/providers/import already returns per-row `{index,name,provider,message}`. + * The modal used to keep only success/failed/total and drop `errors` on the floor. + * These helpers stay a pure, dependency-free module so the hook can stay under the + * LOC ratchet and the same formatter can be unit-tested without React. + */ + +export type ImportRowError = { + index?: number; + name?: string; + provider?: string; + message: string; +}; + +export type ImportResult = { + success: number; + failed: number; + total: number; + errors: ImportRowError[]; +}; + +const VISIBLE_ERROR_CAP = 10; + +function asFiniteNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function asRowError(value: unknown): ImportRowError | null { + if (!value || typeof value !== "object") return null; + const row = value as Record; + if (typeof row.message !== "string" || !row.message.trim()) return null; + return { + ...(typeof row.index === "number" && Number.isFinite(row.index) ? { index: row.index } : {}), + ...(typeof row.name === "string" && row.name.trim() ? { name: row.name.trim() } : {}), + ...(typeof row.provider === "string" && row.provider.trim() ? { provider: row.provider.trim() } : {}), + message: row.message.trim(), + }; +} + +/** Keep counts plus a sanitized `errors` array. A missing/non-array field becomes []. */ +export function normalizeImportResponse(data: unknown): ImportResult { + const body = data && typeof data === "object" ? (data as Record) : {}; + const rawErrors = Array.isArray(body.errors) ? body.errors : []; + return { + success: asFiniteNumber(body.success), + failed: asFiniteNumber(body.failed), + total: asFiniteNumber(body.total), + errors: rawErrors.map(asRowError).filter((row): row is ImportRowError => row !== null), + }; +} + +export type ImportHttpOutcome = { + result: ImportResult; + shouldRefresh: boolean; +}; + +function httpFailureResult(status: number, data: unknown, fallback: ImportResult): ImportResult { + if (fallback.errors.length > 0) { + return { ...fallback, success: 0 }; + } + const body = data && typeof data === "object" ? (data as Record) : {}; + const detail = typeof body.error === "string" ? body.error.trim() : ""; + const message = detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`; + return { + success: 0, + failed: Math.max(1, fallback.failed), + total: Math.max(1, fallback.total), + errors: [{ message }], + }; +} + +/** + * Map an import HTTP response onto the modal result. + * Non-ok statuses still populate `errors`. Refresh is a boolean so the hook + * can await `onImported` outside this function (a throw there must not + * overwrite a successful import result). + */ +export function applyImportHttpOutcome( + res: { ok: boolean; status: number }, + data: unknown +): ImportHttpOutcome { + const normalized = normalizeImportResponse(data); + if (!res.ok) { + return { result: httpFailureResult(res.status, data, normalized), shouldRefresh: false }; + } + return { result: normalized, shouldRefresh: normalized.success > 0 }; +} + +/** Parse the import response body. Non-JSON becomes `{ ok: false, data: { error } }`. */ +export async function readImportResponse(res: Response): Promise<{ + ok: boolean; + status: number; + data: unknown; +}> { + try { + return { ok: res.ok, status: res.status, data: await res.json() }; + } catch { + return { ok: false, status: res.status, data: { error: "Invalid JSON body" } }; + } +} + +export function networkImportFailure(err: unknown): ImportResult { + return { + success: 0, + failed: 1, + total: 1, + errors: [{ message: err instanceof Error ? err.message : "Import request failed" }], + }; +} + +/** First 10 rows plus the leftover count — same cap as AddApiKeyModal bulk import. */ +export function visibleImportErrors(errors: ImportRowError[]): { + shown: ImportRowError[]; + extra: number; +} { + return { + shown: errors.slice(0, VISIBLE_ERROR_CAP), + extra: Math.max(0, errors.length - VISIBLE_ERROR_CAP), + }; +} + +/** One line for the modal list: name, else provider, else 1-based row index. */ +export function formatImportErrorLine(err: ImportRowError): string { + const label = + (typeof err.name === "string" && err.name.trim()) || + (typeof err.provider === "string" && err.provider.trim()) || + (typeof err.index === "number" && Number.isFinite(err.index) ? `row ${err.index + 1}` : "row"); + return `${label}: ${err.message}`; +} + +/** + * Positional CSV sample. Column 0 must be an *existing* managed provider id + * or an already-registered OpenAI/Anthropic-compatible node id — this import + * does not create new endpoint nodes. Header names are cosmetic; the parser + * destructures by index (`provider,name,apiKey,baseUrl,priority`). + */ +export const PROVIDER_IMPORT_CSV_TEMPLATE = `# OmniRoute provider import (positional columns) +# Columns: provider, name, apiKey, baseUrl (optional), priority (optional, 1-100) +# The provider column must be an existing managed provider id (openai, anthropic, …) +# or an already-registered OpenAI/Anthropic-compatible node id. +# This import does not create new endpoint nodes. Add those first (Dashboard → Providers → Add OpenAI-Compatible). +provider,name,apiKey,baseUrl,priority +openai,Prod OpenAI,sk-your-openai-key,,1 +`; + +export function downloadTextFile(content: string, filename: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + try { + document.body.appendChild(link); + link.click(); + } finally { + link.remove(); + URL.revokeObjectURL(url); + } +} + +export function downloadProviderImportCsvTemplate(): void { + downloadTextFile(PROVIDER_IMPORT_CSV_TEMPLATE, "omniroute-provider-import-template.csv", "text/csv"); +} diff --git a/src/app/(dashboard)/dashboard/providers/components/useImportProvidersFromFile.ts b/src/app/(dashboard)/dashboard/providers/components/useImportProvidersFromFile.ts index ecf50e5bcb..a09f00e0c4 100644 --- a/src/app/(dashboard)/dashboard/providers/components/useImportProvidersFromFile.ts +++ b/src/app/(dashboard)/dashboard/providers/components/useImportProvidersFromFile.ts @@ -4,13 +4,17 @@ import { type ParsedProviderImportEntry, type ProviderImportParseError, } from "./parseProviderImportFile"; +import { + applyImportHttpOutcome, + networkImportFailure, + readImportResponse, + type ImportResult, +} from "./providerImportFeedback"; -export type ImportResult = { success: number; failed: number; total: number }; +export type { ImportResult }; /** - * All state + handlers for `ImportProvidersFromFileModal`, split into a hook purely - * to keep the component's own function under the repo's max-lines-per-function ratchet - * (#6836). Behavior is unchanged — this is a pure extraction, not a refactor. + * State + handlers for ImportProvidersFromFileModal (#6836/#12071). */ export function useImportProvidersFromFile(onImported: () => Promise) { const fileInputRef = useRef(null); @@ -66,15 +70,19 @@ export function useImportProvidersFromFile(onImported: () => Promise) { setImporting(true); try { const res = await fetch("/api/providers/import", { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ entries: toImport }), }); - const data = await res.json().catch(() => ({})); - if (res.ok) { - setResult({ success: data.success ?? 0, failed: data.failed ?? 0, total: data.total ?? 0 }); - await onImported(); + const parsed = await readImportResponse(res); + const outcome = applyImportHttpOutcome(parsed, parsed.data); + setResult(outcome.result); + if (outcome.shouldRefresh) { + try { + await onImported(); + } catch { /* refresh failure must not replace the import result */ } } + } catch (err) { + setResult(networkImportFailure(err)); } finally { setImporting(false); } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 292d76c86d..9424b64ed7 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5067,6 +5067,9 @@ "importFromFileImporting": "Importing…", "importFromFileImport": "Import {count} providers", "importFromFileResult": "Imported {success} providers ({failed} failed)", + "importFromFileDownloadTemplate": "Download CSV template", + "importFromFileMoreErrors": "+{count} more", + "importFromFileSchemaHint": "CSV columns are positional: provider, name, apiKey, baseUrl (optional), priority (optional). The provider column must be an existing managed provider id or an already-registered OpenAI/Anthropic-compatible node id — this import does not create new endpoint nodes.", "adaptaTutorial": { "title": "How to connect Adapta Web", "introPrefix": "Adapta authenticates through Clerk. The token", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 7c4d1eb12c..5199f22e2c 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -5068,6 +5068,9 @@ "importFromFileImporting": "Importando…", "importFromFileImport": "Importar {count} provedores", "importFromFileResult": "{success} provedores importados ({failed} falharam)", + "importFromFileDownloadTemplate": "Baixar modelo CSV", + "importFromFileMoreErrors": "+{count} mais", + "importFromFileSchemaHint": "As colunas CSV são posicionais: provider, name, apiKey, baseUrl (opcional), priority (opcional). A coluna provider deve ser o id de um provedor gerenciado existente ou o id de um nó compatível com OpenAI/Anthropic já registrado — esta importação não cria novos nós de endpoint.", "adaptaTutorial": { "title": "Como conectar o Adapta Web", "introPrefix": "Adapta autentica através do Clerk. O token", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 61287b0446..874cab83b8 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -5068,6 +5068,9 @@ "importFromFileImporting": "Đang nhập…", "importFromFileImport": "Nhập {count} nhà cung cấp", "importFromFileResult": "Đã nhập {success} nhà cung cấp ({failed} không thành công)", + "importFromFileDownloadTemplate": "Tải mẫu CSV", + "importFromFileMoreErrors": "+{count} nữa", + "importFromFileSchemaHint": "Các cột CSV theo vị trí: provider, name, apiKey, baseUrl (tùy chọn), priority (tùy chọn). Cột provider phải là id nhà cung cấp được quản lý hiện có hoặc id nút tương thích OpenAI/Anthropic đã đăng ký — quá trình nhập này không tạo nút endpoint mới.", "adaptaTutorial": { "title": "How to connect Adapta Web", "introPrefix": "Adapta authenticates through Clerk. The token", diff --git a/tests/unit/provider-import-feedback-12071.test.ts b/tests/unit/provider-import-feedback-12071.test.ts new file mode 100644 index 0000000000..8db247a84e --- /dev/null +++ b/tests/unit/provider-import-feedback-12071.test.ts @@ -0,0 +1,143 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const feedback = await import( + "../../src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts" +); +const { parseProviderImportFile } = await import( + "../../src/app/(dashboard)/dashboard/providers/components/parseProviderImportFile.ts" +); + +test("#12071 normalizeImportResponse keeps the per-row errors array", () => { + const result = feedback.normalizeImportResponse({ + success: 1, + failed: 2, + total: 3, + errors: [ + { index: 1, name: "srv-107", provider: "openai-compatible-chat-001", message: "Unknown or unsupported provider" }, + { index: 2, name: "srv-135", provider: "openai", message: "Provider node not found" }, + ], + }); + assert.equal(result.success, 1); + assert.equal(result.failed, 2); + assert.equal(result.total, 3); + assert.equal(result.errors.length, 2); + assert.equal(result.errors[0].message, "Unknown or unsupported provider"); + assert.equal(result.errors[1].name, "srv-135"); +}); + +test("#12071 normalizeImportResponse treats a missing errors field as [] (today's silent drop)", () => { + const result = feedback.normalizeImportResponse({ success: 0, failed: 3, total: 3 }); + assert.deepEqual(result.errors, []); + assert.equal(result.failed, 3); +}); + +test("#12071 normalizeImportResponse ignores a non-array errors field", () => { + const result = feedback.normalizeImportResponse({ success: 0, failed: 1, total: 1, errors: "boom" }); + assert.deepEqual(result.errors, []); +}); + +test("#12071 visibleImportErrors caps at 10 and reports the remainder", () => { + const errors = Array.from({ length: 12 }, (_, i) => ({ message: `row ${i}` })); + const { shown, extra } = feedback.visibleImportErrors(errors); + assert.equal(shown.length, 10); + assert.equal(extra, 2); + assert.equal(shown[0].message, "row 0"); +}); + +test("#12071 formatImportErrorLine prefers name, then provider, then 1-based row", () => { + assert.equal( + feedback.formatImportErrorLine({ name: "Grade-S-Node", message: "Unknown or unsupported provider" }), + "Grade-S-Node: Unknown or unsupported provider" + ); + assert.equal( + feedback.formatImportErrorLine({ provider: "openai", message: "Provider node not found" }), + "openai: Provider node not found" + ); + assert.equal(feedback.formatImportErrorLine({ index: 0, message: "failed" }), "row 1: failed"); +}); + +test("#12071 CSV template is positional and parses to one openai row", () => { + const parsed = parseProviderImportFile(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, "csv"); + assert.equal(parsed.errors.length, 0); + assert.equal(parsed.entries.length, 1); + assert.equal(parsed.entries[0].provider, "openai"); + assert.equal(parsed.entries[0].name, "Prod OpenAI"); + assert.equal(parsed.entries[0].apiKey, "sk-your-openai-key"); + assert.equal(parsed.entries[0].priority, 1); +}); + +test("#12071 CSV template comments document that provider must already exist", () => { + assert.match(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, /existing managed provider/i); + assert.match(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, /does not create new endpoint nodes/i); + assert.match(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, /positional/i); +}); + +test("#12071 asRowError trims leading/trailing whitespace on message", () => { + const result = feedback.normalizeImportResponse({ + success: 0, + failed: 1, + total: 1, + errors: [{ name: "srv-107", message: " Unknown or unsupported provider " }], + }); + assert.equal(result.errors.length, 1); + assert.equal(result.errors[0].message, "Unknown or unsupported provider"); +}); + +test("#12071 applyImportHttpOutcome on !ok zeros success even if the body claimed some", () => { + const outcome = feedback.applyImportHttpOutcome( + { ok: false, status: 500 }, + { + success: 5, + failed: 0, + total: 5, + errors: [{ name: "a", message: "Unknown or unsupported provider" }], + } + ); + assert.equal(outcome.shouldRefresh, false); + assert.equal(outcome.result.success, 0); + assert.equal(outcome.result.errors.length, 1); +}); + +test("#12071 applyImportHttpOutcome surfaces non-ok HTTP without calling onImported", () => { + const outcome = feedback.applyImportHttpOutcome( + { ok: false, status: 400 }, + { error: "Invalid JSON body" } + ); + assert.equal(outcome.shouldRefresh, false); + assert.equal(outcome.result.success, 0); + assert.equal(outcome.result.failed, 1); + assert.equal(outcome.result.errors.length, 1); + assert.match(outcome.result.errors[0].message, /HTTP 400/); +}); + +test("#12071 applyImportHttpOutcome on ok with success>0 requests refresh", () => { + const outcome = feedback.applyImportHttpOutcome( + { ok: true, status: 200 }, + { success: 2, failed: 1, total: 3, errors: [{ name: "bad", message: "Unknown or unsupported provider" }] } + ); + assert.equal(outcome.shouldRefresh, true); + assert.equal(outcome.result.success, 2); + assert.equal(outcome.result.failed, 1); + assert.equal(outcome.result.errors[0].name, "bad"); +}); + +test("#12071 applyImportHttpOutcome on ok with success=0 still keeps errors and skips refresh", () => { + const outcome = feedback.applyImportHttpOutcome( + { ok: true, status: 200 }, + { success: 0, failed: 3, total: 3, errors: [{ name: "a", message: "Unknown or unsupported provider" }] } + ); + assert.equal(outcome.shouldRefresh, false); + assert.equal(outcome.result.success, 0); + assert.equal(outcome.result.errors.length, 1); +}); + +test("#12071 readImportResponse treats JSON parse failure as !ok with a body error", async () => { + const res = new Response("not-json", { status: 200, headers: { "Content-Type": "text/plain" } }); + const parsed = await feedback.readImportResponse(res); + assert.equal(parsed.ok, false); + assert.equal(parsed.status, 200); + const outcome = feedback.applyImportHttpOutcome(parsed, parsed.data); + assert.equal(outcome.shouldRefresh, false); + assert.match(outcome.result.errors[0].message, /Invalid JSON body/); +});