UNPKG

ethercalc

Version:

Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)

378 lines (342 loc) 13.1 kB
/** * Batch repro checker for trivial-to-test open GitHub issues. * Run: bun scripts/repro-open-issues.ts [--http http://127.0.0.1:8787] */ import { createSpreadsheet } from '../packages/socialcalc-headless/src/index.ts'; type Verdict = 'fixed' | 'broken' | 'unchanged' | 'skip'; interface Result { issue: number; title: string; verdict: Verdict; detail: string; } const results: Result[] = []; function record( issue: number, title: string, verdict: Verdict, detail: string, ): void { results.push({ issue, title, verdict, detail }); } function cellVal(ss: ReturnType<typeof createSpreadsheet>, coord: string): unknown { const c = ss.exportCell(coord) as { datavalue?: unknown; formula?: string } | null; return c?.datavalue ?? c?.formula ?? null; } function cellText(ss: ReturnType<typeof createSpreadsheet>, coord: string): string { const c = ss.exportCell(coord) as { datavalue?: unknown } | null; return c?.datavalue == null ? '' : String(c.datavalue); } // ─── Headless SocialCalc formula/export repros ───────────────────────── function test506(): void { const ss = createSpreadsheet(); ss.executeCommand('set A1 text t hello\nset A2 text t world\nset A3 formula CONCAT(A1,A2)\nrecalc'); const csv = ss.exportCSV(); const a3 = cellText(ss, 'A3'); const broken = csv.includes('Unknown function') || a3.includes('Unknown'); record(506, 'CONCAT export', broken ? 'broken' : 'fixed', `A3=${a3!}, csv tail=${csv.split('\n').pop()}`); } function test511(): void { const ss = createSpreadsheet(); const cmds = ['set C1 value n 4.01']; for (let i = 1; i <= 20; i++) cmds.push(`set A${i} value n ${i * 0.5}`); cmds.push('set B1 formula COUNTIF(A1:A20,">4")'); cmds.push('set B2 formula COUNTIF(A1:A20,">"&C1)'); cmds.push('recalc'); ss.executeCommand(cmds.join('\n')); const b1 = cellVal(ss, 'B1'); const b2 = cellVal(ss, 'B2'); const broken = Number(b2) === 0 || Number.isNaN(Number(b2)); record(511, 'COUNTIF decimal', broken ? 'broken' : 'fixed', `B1=${b1}, B2=${b2}`); } function test534(): void { const ss = createSpreadsheet(); for (let r = 1; r <= 5; r++) ss.executeCommand(`set N${r} value n ${r}`, false); ss.executeCommand('set A1 formula SUM(N:N)\nrecalc'); const a1 = cellText(ss, 'A1'); const broken = a1.includes('?') || a1.includes('Wrong') || a1.includes('Falsche') || a1 === '0'; record(534, 'SUM(N:N)', broken ? 'broken' : 'fixed', `A1=${a1}`); } function test260(): void { const ss = createSpreadsheet(); ss.executeCommand('set A1 formula B1+1\nrecalc'); const a1 = cellVal(ss, 'A1'); // Blank B1 → SocialCalc treats as 0 → A1 becomes 1 const treatsBlankAsZero = Number(a1) === 1; record( 260, 'Blank cell as zero', treatsBlankAsZero ? 'unchanged' : 'fixed', `A1=B1+1 with empty B1 → ${a1} (0=unchanged SocialCalc semantics)`, ); } function test577(): void { const ss = createSpreadsheet(); ss.executeCommand( [ 'set A1 value n 142.234', 'format A1 value-numberformat $#,##0.00', 'set B1 formula A1*3', 'set C1 formula A1/2', 'set D1 formula A1*(1/2)', 'recalc', ].join('\n'), ); const b1 = cellText(ss, 'B1'); const c1 = cellText(ss, 'C1'); const d1 = cellText(ss, 'D1'); const broken = c1.includes('71.117') && !c1.includes('$'); record(577, 'Money /2 coercion', broken ? 'unchanged' : 'fixed', `B1=${b1}, C1=${c1}, D1=${d1}`); } function test304(): void { const csv = `,Input,Squares A,3,=B2*B2 B,12,=B3*B3 ,, ,,=SUM(B2:B3)`; const ss = createSpreadsheet(); ss.executeCommand(`loadclipboard socialcalc${csv}\nrecalc`); const c3 = ss.exportCell('C3') as { datavalue?: unknown; formula?: string } | null; const c4 = ss.exportCell('C4') as { datavalue?: unknown; formula?: string } | null; const hasFormula = Boolean(c3?.formula || c4?.formula); const quotedLiteral = String(c3?.datavalue ?? '').startsWith("'") || String(c4?.datavalue ?? '').startsWith("'"); record( 304, 'CSV formulas as literals', quotedLiteral && !hasFormula ? 'unchanged' : hasFormula ? 'fixed' : 'broken', `C3=${JSON.stringify(c3)}, C4=${JSON.stringify(c4)}`, ); } function test638(): void { const ss = createSpreadsheet(); ss.executeCommand( 'set A1 value n 1.9859735\nformat A1 value-numberformat #,##0\nrecalc', ); const csv = ss.exportCSV().trim(); record( 638, 'CSV export rounding', csv === '2' ? 'fixed' : 'unchanged', `formatted #,##0 exported as "${csv}"`, ); } function test355(): void { const ss = createSpreadsheet(); ss.executeCommand('set B1 value t 1/1/1990\nrecalc'); const csv = ss.exportCSV().trim(); const broken = csv === '32874' || /^\d{5}$/.test(csv); record(355, 'Date export as serial', broken ? 'unchanged' : 'fixed', `B1 csv="${csv}"`); } function test646(): void { const ss = createSpreadsheet(); ss.executeCommand('set A1 value t 11:30\nrecalc'); const csv = ss.exportCSV().trim(); const broken = csv.includes('0.4791'); record(646, 'Hour as float in export', broken ? 'unchanged' : 'fixed', `A1 csv="${csv}"`); } function test122(): void { const ss = createSpreadsheet(); for (let i = 1; i <= 5; i++) ss.executeCommand(`set A${i} value n 1`, false); ss.executeCommand('set B1 formula COUNT(A:A)\nrecalc'); const b1 = cellVal(ss, 'B1'); record(122, 'COUNT(A:A) after setup', Number(b1) === 5 ? 'fixed' : 'broken', `COUNT(A:A)=${b1}`); } function test19(): void { const ss = createSpreadsheet(); for (let r = 2; r <= 6; r++) ss.executeCommand(`set B${r} value n 1`, false); const t0 = performance.now(); ss.executeCommand('set A1 formula SUM(B2:B100000)', false); const ms = performance.now() - t0; record(19, 'Huge SUM freeze', ms > 500 ? 'unchanged' : 'fixed', `SUM(B2:B100000) took ${ms.toFixed(0)}ms`); } function _test514(): void { record(514, 'Wiki syntax export', 'skip', 'Feature request — no /md route tested here'); } // ─── HTTP repros (optional) ─────────────────────────────────────────── async function httpRepros(base: string): Promise<void> { const uid = () => `repro-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // #119 illegal POST content-type { const room = uid(); await fetch(`${base}/_/${room}`, { method: 'PUT', headers: { 'Content-Type': 'text/plain' }, body: 'cell:A1:t:hello\n', }); await fetch(`${base}/_/${room}`, { method: 'POST', body: '1,2,3', // missing Content-Type }); const csv = await fetch(`${base}/_/${room}/csv`); const html = await fetch(`${base}/_/${room}/html`); record( 119, 'GET export after bad POST', csv.ok && html.ok ? 'fixed' : 'broken', `csv=${csv.status}, html=${html.status}`, ); } // #385 / recalc via POST { const room = uid(); await fetch(`${base}/_/${room}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: ['set A1 value n 1', 'set A2 value n 2', 'set A3 formula SUM(A1:A2)'] }), }); const cells = await fetch(`${base}/_/${room}/cells/A3`); const j = (await cells.json()) as { datavalue?: number }; record(385, 'Recalc after API POST', j.datavalue === 3 ? 'fixed' : 'broken', `A3=${j.datavalue}`); } // #304 via PUT csv { const room = uid(); const csv = '1,=A1*2\n2,=A2*2\n'; await fetch(`${base}/_/${room}`, { method: 'PUT', headers: { 'Content-Type': 'text/csv' }, body: csv, }); const snap = await fetch(`${base}/_/${room}`); const body = await snap.text(); const hasFormula = body.includes('formula'); record(304, 'PUT csv preserves formulas', hasFormula ? 'fixed' : 'unchanged', hasFormula ? 'snapshot has formula' : 'no formula in snapshot'); } // #442 DELETE + formdata sibling { const room = uid(); await fetch(`${base}/_/${room}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'set A1 text t hi' }), }); // Seed formdata sibling directly via migrate seed if available, else WS path skipped const formRoom = `${room}_formdata`; const token = process.env.ETHERCALC_MIGRATE_TOKEN; if (token) { await fetch(`${base}/_migrate/seed/${formRoom}`, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'text/plain' }, body: 'cell:A1:t:form\n', }); await fetch(`${base}/_/${room}`, { method: 'DELETE' }); const main = await fetch(`${base}/_exists/${room}`); const form = await fetch(`${base}/_exists/${formRoom}`); const mainJ = await main.json(); const formJ = await form.json(); record( 442, 'DELETE cascades _formdata', mainJ === false && formJ === false ? 'fixed' : 'broken', `exists main=${mainJ}, form=${formJ}`, ); } else { record(442, 'DELETE cascades _formdata', 'skip', 'ETHERCALC_MIGRATE_TOKEN unset — cannot seed sibling'); } } // #533 formdata in room index { const rooms = await fetch(`${base}/_rooms`); if (rooms.status === 403) { record(533, '_formdata in _rooms', 'skip', '_rooms gated (403)'); } else { const list = (await rooms.json()) as string[]; const dupes = list.filter((r) => r.endsWith('_formdata')); record( 533, '_formdata in _rooms', dupes.length === 0 ? 'fixed' : 'unchanged', dupes.length ? `sample: ${dupes.slice(0, 3).join(', ')}` : 'no _formdata rooms listed', ); } } // #275 multi-sheet redirect — check route exists { const room = uid(); const res = await fetch(`${base}/_/${room}`, { redirect: 'manual' }); record(275, 'Multi-sheet redirect', res.status < 500 ? 'skip' : 'broken', `GET /_/:room → ${res.status} (redirect logic needs multi room)`); } // #514 markdown export { const room = uid(); await fetch(`${base}/_/${room}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'set A1 text t hi' }), }); const md = await fetch(`${base}/_/${room}/md`); record(514, 'Wiki/markdown export', md.ok ? 'fixed' : 'broken', `GET /md → ${md.status}`); } // #638 / #355 / #646 via HTTP export { const room = uid(); await fetch(`${base}/_/${room}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: [ 'set A1 value n 1.9859735', 'format A1 value-numberformat #,##0', 'set B1 value t 1/1/1990', 'recalc', ], }), }); const csv = (await (await fetch(`${base}/_/${room}/csv`)).text()).trim(); // only annotate if not already set by headless if (!results.find((r) => r.issue === 638)) { record(638, 'CSV export rounding (HTTP)', csv.startsWith('2') ? 'fixed' : 'unchanged', `csv="${csv.split('\n')[0]}"`); } } // xlsx export has Content-Disposition (was #367, closed) { const room = uid(); await fetch(`${base}/_/${room}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'set A1 text t x' }), }); const xlsx = await fetch(`${base}/_/${room}/xlsx`); const cd = xlsx.headers.get('Content-Disposition') ?? ''; record(367, 'Content-Disposition (verify)', cd.includes('attachment') ? 'fixed' : 'broken', cd || '(missing)'); } } // ─── main ───────────────────────────────────────────────────────────── test19(); test506(); test511(); test534(); test260(); test577(); test304(); test638(); test355(); test646(); test122(); const httpBase = process.argv.find((a) => a.startsWith('--http='))?.slice(7) ?? (process.argv.includes('--http') ? process.argv[process.argv.indexOf('--http') + 1] : undefined); if (httpBase) { try { const health = await fetch(`${httpBase}/_health`); if (!health.ok) throw new Error(`health ${health.status}`); await httpRepros(httpBase); } catch (e) { console.error(`HTTP repros skipped: ${e}`); } } else { record(119, 'HTTP repros', 'skip', 'pass --http URL'); record(385, 'HTTP repros', 'skip', 'pass --http URL'); record(442, 'HTTP repros', 'skip', 'pass --http URL'); record(533, 'HTTP repros', 'skip', 'pass --http URL'); } const byVerdict = (v: Verdict) => results.filter((r) => r.verdict === v); console.log('\n=== REPRO RESULTS ===\n'); for (const v of ['fixed', 'broken', 'unchanged', 'skip'] as const) { const group = byVerdict(v); if (!group.length) continue; console.log(`## ${v.toUpperCase()} (${group.length})`); for (const r of group.sort((a, b) => a.issue - b.issue)) { console.log(` #${r.issue} ${r.title}: ${r.detail}`); } console.log(); }