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
+23 -1
View File
@@ -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;");
+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 }) => {
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.