diff --git a/components/runner.tsx b/components/runner.tsx
index 8a462f3..4c255a2 100644
--- a/components/runner.tsx
+++ b/components/runner.tsx
@@ -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() {
{
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 && {at + 1} of 4 ยท {LABEL[STAGES[at]]}}
diff --git a/lib/digest.ts b/lib/digest.ts
index 5ca4fc2..2e1e917 100644
--- a/lib/digest.ts
+++ b/lib/digest.ts
@@ -36,7 +36,13 @@ export function digestPlan(raw: string): Digest {
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.`);
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.");
const stmts = collect(doc, "StmtSimple");
diff --git a/lib/plan-file.ts b/lib/plan-file.ts
new file mode 100644
index 0000000..dee81cc
--- /dev/null
+++ b/lib/plan-file.ts
@@ -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";
+}
+
diff --git a/tests-e2e/analysis.spec.ts b/tests-e2e/analysis.spec.ts
index 715d6f2..b10ac3e 100644
--- a/tests-e2e/analysis.spec.ts
+++ b/tests-e2e/analysis.spec.ts
@@ -1,5 +1,5 @@
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");
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");
});
+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(" {
+ 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 }) => {
await page.goto("/app");
await page.fill("#xml", "SELECT * FROM dbo.Orders WHERE CustomerId = 42;");
diff --git a/tests-e2e/history.spec.ts b/tests-e2e/history.spec.ts
index c3a0a15..f969970 100644
--- a/tests-e2e/history.spec.ts
+++ b/tests-e2e/history.spec.ts
@@ -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 }) => {
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.getByRole("button", { name: "Analyse" })).toHaveCount(0);
// The stored Jev verdict is server-rendered, so it is readable even if the report body had failed.
diff --git a/tests/digest.test.ts b/tests/digest.test.ts
index dd2fef3..48d8b32 100644
--- a/tests/digest.test.ts
+++ b/tests/digest.test.ts
@@ -76,3 +76,35 @@ test("a real .sqlplan digests", () => {
assert.ok(d.topOperators.length > 0, "no operators found โ check attribute spellings");
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 = '';
+
+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(" {
+ const { PlanError } = await import("../lib/digest.ts");
+ const e: unknown = (() => { try { digestPlan(""); 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.");
+});