fix: decode .sqlplan files the way SSMS writes them; name the parse failure

- lib/plan-file.ts: BOM-aware decode for UTF-8, UTF-16LE/BE (with and without BOM) and UTF-32, with a NUL-density fallback for BOM-less UTF-16. The browser had a two-byte FF FE sniff only, so a UTF-16BE or BOM-less plan arrived as NUL-filled junk and the digest blamed the export.
- digestPlan now quotes fast-xml-parser's first line instead of a fixed sentence, so a truncated file, a stray entity and a wrong file stay distinguishable.
- plan-file.ts holds the decode so the XML parser stays out of the client bundle.
- tests: 6 encodings round-trip through decodePlan into a digest; a truncated plan's message is asserted to carry a reason; e2e drops a real UTF-16LE file. 36 unit, 23 e2e.
- history.spec picked the rail's first row, which is now one of the new failing-input rows; it selects the newest finished run instead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ahmed mohammed
2026-09-22 11:08:55 +03:00
co-authored by Claude Opus 4.7
parent 3d1866367b
commit 4b5c19b5d2
6 changed files with 93 additions and 7 deletions
+3 -4
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { readSse } from "@/lib/stream";
import { decodePlan } from "@/lib/plan-file.ts";
import { ReportView } from "./report-view";
import { ExportButtons } from "./export-buttons";
@@ -59,10 +60,8 @@ export function Runner() {
<input type="file" accept=".sqlplan,.xml" aria-label="Load a .sqlplan file" style={{ fontSize: "var(--qai-text-xs)" }}
onChange={async (e) => {
const f = e.target.files?.[0]; if (!f) return;
const buf = new Uint8Array(await f.arrayBuffer());
// SSMS saves UTF-16LE; File.text() decodes that as UTF-8 and fills the plan with NUL bytes.
const utf16 = buf[0] === 0xff && buf[1] === 0xfe;
setXml(new TextDecoder(utf16 ? "utf-16le" : "utf-8").decode(buf).replace(/^/, ""));
// SSMS saves UTF-16LE; File.text() would decode that as UTF-8 and fill the plan with NULs.
setXml(decodePlan(new Uint8Array(await f.arrayBuffer())));
}} />
{at >= 0 && <span className="pill" aria-live="polite">{at + 1} of 4 · {LABEL[STAGES[at]]}</span>}
</div>