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."); +});