/** * Minimal CSV parser for test-plan.csv. Supports quoted fields containing * commas, escaped double-quotes (""), and arbitrary trailing columns. Returns * one Scenario per non-header row. * * Schema (header row must include at least id, description, inputs, expected; * any extra columns are surfaced verbatim in `extra` for future use): * * id,description,inputs,expected * T1,Quote 35yo male non-smoker,age=35;gender=male;smoker=no;cover=100000,premium between 140 and 160 * * Deliberately hand-rolled — pulling in csv-parse adds 100KB+ for a parser * that lives behind a single user-facing input and never sees adversarial CSV. */ export interface Scenario { id: string; description: string; inputs: string; expected: string; extra: Record; } /** Tokenise one CSV row honouring "double quotes" and escaped "" pairs. */ export const parseCsvRow = (row: string): string[] => { const fields: string[] = []; let cur = ''; let inQuotes = false; for (let i = 0; i < row.length; i++) { const ch = row[i]; if (inQuotes) { if (ch === '"' && row[i + 1] === '"') { cur += '"'; i++; } else if (ch === '"') { inQuotes = false; } else { cur += ch; } } else if (ch === ',') { fields.push(cur); cur = ''; } else if (ch === '"' && cur.length === 0) { inQuotes = true; } else { cur += ch; } } fields.push(cur); return fields.map((f) => f.trim()); }; const REQUIRED_COLUMNS = ['id', 'description', 'inputs', 'expected'] as const; export const parseTestPlanCsv = (raw: string): Scenario[] => { const lines = raw .split(/\r?\n/) .map((l) => l.trim()) .filter((l) => l.length > 0 && !l.startsWith('#')); if (lines.length < 2) { throw new Error('test-plan.csv must have a header row and at least one scenario row'); } const header = parseCsvRow(lines[0]).map((h) => h.toLowerCase()); for (const required of REQUIRED_COLUMNS) { if (!header.includes(required)) { throw new Error(`test-plan.csv header missing required column "${required}". Got: ${header.join(', ')}`); } } const scenarios: Scenario[] = []; for (let i = 1; i < lines.length; i++) { const values = parseCsvRow(lines[i]); if (values.length < REQUIRED_COLUMNS.length) { throw new Error( `test-plan.csv row ${i + 1}: expected at least ${REQUIRED_COLUMNS.length} columns, got ${values.length}`, ); } const row: Record = {}; header.forEach((col, idx) => { row[col] = values[idx] ?? ''; }); const id = row.id; if (!id) { throw new Error(`test-plan.csv row ${i + 1}: id column is empty`); } const extra: Record = {}; Object.keys(row).forEach((key) => { if (!REQUIRED_COLUMNS.includes(key as (typeof REQUIRED_COLUMNS)[number])) { extra[key] = row[key]; } }); scenarios.push({ id, description: row.description, inputs: row.inputs, expected: row.expected, extra, }); } // Reject duplicate ids — they'd silently overwrite each other in any report. const seen = new Set(); for (const s of scenarios) { if (seen.has(s.id)) { throw new Error(`test-plan.csv: duplicate scenario id "${s.id}"`); } seen.add(s.id); } return scenarios; };