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"; "use client";
import { useState } from "react"; import { useState } from "react";
import { readSse } from "@/lib/stream"; import { readSse } from "@/lib/stream";
import { decodePlan } from "@/lib/plan-file.ts";
import { ReportView } from "./report-view"; import { ReportView } from "./report-view";
import { ExportButtons } from "./export-buttons"; 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)" }} <input type="file" accept=".sqlplan,.xml" aria-label="Load a .sqlplan file" style={{ fontSize: "var(--qai-text-xs)" }}
onChange={async (e) => { onChange={async (e) => {
const f = e.target.files?.[0]; if (!f) return; const f = e.target.files?.[0]; if (!f) return;
const buf = new Uint8Array(await f.arrayBuffer()); // SSMS saves UTF-16LE; File.text() would decode that as UTF-8 and fill the plan with NULs.
// SSMS saves UTF-16LE; File.text() decodes that as UTF-8 and fills the plan with NUL bytes. setXml(decodePlan(new Uint8Array(await f.arrayBuffer())));
const utf16 = buf[0] === 0xff && buf[1] === 0xfe;
setXml(new TextDecoder(utf16 ? "utf-16le" : "utf-8").decode(buf).replace(/^/, ""));
}} /> }} />
{at >= 0 && <span className="pill" aria-live="polite">{at + 1} of 4 · {LABEL[STAGES[at]]}</span>} {at >= 0 && <span className="pill" aria-live="polite">{at + 1} of 4 · {LABEL[STAGES[at]]}</span>}
</div> </div>
+7 -1
View File
@@ -36,7 +36,13 @@ export function digestPlan(raw: string): Digest {
if (xml.length > MAX_XML_BYTES) if (xml.length > MAX_XML_BYTES)
throw new PlanError(`Plan is ${Math.round(xml.length / 1e6)} MB, over the ${MAX_XML_BYTES / 1e6} MB cap.`); throw new PlanError(`Plan is ${Math.round(xml.length / 1e6)} MB, over the ${MAX_XML_BYTES / 1e6} MB cap.`);
let doc: any; let doc: any;
try { doc = parser.parse(xml); } catch { throw new PlanError("That XML will not parse — re-export the actual execution plan."); } // Name the parser's complaint. "re-export the plan" is what a user gets told either way, so a truncated
// file, a stray entity and a genuinely wrong file have to stay distinguishable.
try { doc = parser.parse(xml); }
catch (e: any) {
const why = String(e?.message ?? e).split("\n")[0].slice(0, 140);
throw new PlanError(`The XML stopped parsing: ${why}. Check the file was saved completely (SSMS: Save As, not copy-paste).`);
}
if (!doc?.ShowPlanXML) throw new PlanError("Not a SQL Server ShowPlan XML document. In SSMS: press Ctrl+M, run the query, then save the Execution Plan tab as .sqlplan."); if (!doc?.ShowPlanXML) throw new PlanError("Not a SQL Server ShowPlan XML document. In SSMS: press Ctrl+M, run the query, then save the Execution Plan tab as .sqlplan.");
const stmts = collect(doc, "StmtSimple"); const stmts = collect(doc, "StmtSimple");
+25
View File
@@ -0,0 +1,25 @@
"use client";
// SSMS saves .sqlplan as UTF-16LE with a BOM; other tools write UTF-8 (sometimes with a BOM), and a few write
// UTF-16BE or BOM-less UTF-16. File.text() assumes UTF-8, which turns a UTF-16 plan into NUL-filled junk that
// fails to parse with a message blaming the user's export. BOM first, then a NUL-density guess.
export function decodePlan(buf: Uint8Array): string {
const enc = utfEncoding(buf);
return new TextDecoder(enc).decode(buf).replace(/^\uFEFF/, "");
}
function utfEncoding(buf: Uint8Array): "utf-8" | "utf-16le" | "utf-16be" | "utf-32le" | "utf-32be" {
const b = (i: number) => buf[i] ?? 0;
if (b(0) === 0xff && b(1) === 0xfe && b(2) === 0 && b(3) === 0) return "utf-32le";
if (b(0) === 0 && b(1) === 0 && b(2) === 0xfe && b(3) === 0xff) return "utf-32be";
if (b(0) === 0xff && b(1) === 0xfe) return "utf-16le";
if (b(0) === 0xfe && b(1) === 0xff) return "utf-16be";
// BOM-less UTF-16 of ASCII XML: in the first 200 bytes roughly every other byte is 0x00.
const n = Math.min(buf.length - (buf.length % 2), 200);
if (n < 40) return "utf-8";
let even = 0, odd = 0;
for (let i = 0; i < n; i += 2) { if (b(i) === 0) even++; if (b(i + 1) === 0) odd++; }
if (odd > n / 6) return "utf-16le";
if (even > n / 6) return "utf-16be";
return "utf-8";
}
+23 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { readFileSync } from "node:fs"; import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
const xml = readFileSync("fixtures/sample.sqlplan", "utf8"); const xml = readFileSync("fixtures/sample.sqlplan", "utf8");
test.use({ storageState: ".auth/user.json", permissions: ["clipboard-read", "clipboard-write"] }); test.use({ storageState: ".auth/user.json", permissions: ["clipboard-read", "clipboard-write"] });
@@ -41,6 +41,28 @@ test("a dropped .sqlplan file decodes and is accepted", async ({ page }) => {
expect(value).not.toContain("\ufeff"); expect(value).not.toContain("\ufeff");
}); });
test("a UTF-16LE .sqlplan, which is what SSMS writes, decodes without NULs", async ({ page }) => {
// fixtures/sample.sqlplan is UTF-8+BOM, so the test above never exercised the SSMS encoding.
mkdirSync(".playwright-data", { recursive: true });
const path = ".playwright-data/upload-utf16le.sqlplan";
const clean = readFileSync("fixtures/sample.sqlplan", "utf8").replace(/^/, "");
writeFileSync(path, Buffer.from("\uFEFF" + clean, "utf16le"));
await page.goto("/app");
await page.setInputFiles('input[type="file"]', path);
const value = await page.locator("#xml").inputValue();
expect(value.startsWith("<?xml")).toBe(true);
expect(value).not.toContain("\u0000");
});
test("a truncated plan names the parse failure instead of blaming the export", async ({ page }) => {
await page.goto("/app");
await page.fill("#xml", readFileSync("fixtures/sample.sqlplan", "utf8").slice(0, 900));
await page.getByRole("button", { name: "Analyse" }).click();
const alert = page.getByRole("alert").filter({ hasText: "digesting" });
await expect(alert).toBeVisible({ timeout: 30_000 });
expect(await alert.innerText()).toMatch(/stopped parsing|Unexpected|unclosed|tag name/i);
});
test("a pasted SQL script is refused with a named reason", async ({ page }) => { test("a pasted SQL script is refused with a named reason", async ({ page }) => {
await page.goto("/app"); await page.goto("/app");
await page.fill("#xml", "SELECT * FROM dbo.Orders WHERE CustomerId = 42;"); await page.fill("#xml", "SELECT * FROM dbo.Orders WHERE CustomerId = 42;");
+3 -1
View File
@@ -23,7 +23,9 @@ test("rename sticks, delete asks first then removes", async ({ page }) => {
test("reopening a stored run renders the report with no new model call", async ({ page }) => { test("reopening a stored run renders the report with no new model call", async ({ page }) => {
await page.goto("/app"); await page.goto("/app");
await page.locator('.rail a[href^="/app/"]').first().click(); // The newest finished run, not the newest row: the encoding tests above leave `failed` rows on top,
// and only unfinished rows carry a status pill in the rail.
await page.locator('.rail a[href^="/app/"]:not(:has(.pill))').first().click();
await expect(page.locator("#report")).toBeVisible({ timeout: 30_000 }); await expect(page.locator("#report")).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Analyse" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Analyse" })).toHaveCount(0);
// The stored Jev verdict is server-rendered, so it is readable even if the report body had failed. // The stored Jev verdict is server-rendered, so it is readable even if the report body had failed.
+32
View File
@@ -76,3 +76,35 @@ test("a real .sqlplan digests", () => {
assert.ok(d.topOperators.length > 0, "no operators found — check attribute spellings"); assert.ok(d.topOperators.length > 0, "no operators found — check attribute spellings");
assert.ok(d.sql.length > 0); assert.ok(d.sql.length > 0);
}); });
// ---- plan file decoding (lib/plan-file.ts: the browser reads a .sqlplan into bytes, then hands text to the digest) ----
const enc = (s: string) => new TextEncoder().encode(s);
const utf16le = (s: string) => { const b = new Uint8Array(s.length * 2); for (let i = 0; i < s.length; i++) { b[i * 2] = s.charCodeAt(i); b[i * 2 + 1] = 0; } return b; };
const utf16be = (s: string) => { const b = new Uint8Array(s.length * 2); for (let i = 0; i < s.length; i++) { b[i * 2] = 0; b[i * 2 + 1] = s.charCodeAt(i); } return b; };
const sample = '<?xml version="1.0"?><ShowPlanXML xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan" Version="1.0"><BatchSequence><Batch><Statements><StmtSimple StatementText="SELECT 1"/></Statements></Batch></BatchSequence></ShowPlanXML>';
test("every .sqlplan encoding SSMS and friends produce arrives as clean text", async () => {
const { decodePlan } = await import("../lib/plan-file.ts");
const cases: [string, Uint8Array][] = [
["utf-8", enc(sample)],
["utf-8 + bom", enc("\uFEFF" + sample)],
["utf-16le + bom (what SSMS writes)", Uint8Array.from([0xff, 0xfe, ...utf16le(sample)])],
["utf-16be + bom", Uint8Array.from([0xfe, 0xff, ...utf16be(sample)])],
["utf-16le, no bom", utf16le(sample)],
["utf-16be, no bom", utf16be(sample)],
];
for (const [name, bytes] of cases) {
const out = decodePlan(bytes);
assert.ok(out.startsWith("<?xml"), `${name}: starts with ${JSON.stringify(out.slice(0, 12))}`);
assert.ok(!out.includes("\0"), `${name}: still contains NUL bytes`);
assert.equal(digestPlan(out).sql, "SELECT 1", `${name}: digest after decode`);
}
});
test("a parse failure says why instead of blaming the export", async () => {
const { PlanError } = await import("../lib/digest.ts");
const e: unknown = (() => { try { digestPlan("<ShowPlanXML><Unclosed></ShowPlanXML>"); return null; } catch (e) { return e; } })();
assert.ok(e instanceof PlanError);
assert.ok(e.message.length > 40, `message too thin: ${e.message}`);
assert.notEqual(e.message, "That XML will not parse — re-export the actual execution plan.");
});