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:
co-authored by
Claude Opus 4.7
parent
3d1866367b
commit
4b5c19b5d2
@@ -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>
|
||||
|
||||
+7
-1
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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("<?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 }) => {
|
||||
await page.goto("/app");
|
||||
await page.fill("#xml", "SELECT * FROM dbo.Orders WHERE CustomerId = 42;");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = '<?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.");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user