From d6afef7d07d2e8a4d34267b32e0f2e48ea66513b Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 6 Jun 2026 12:50:33 +0530 Subject: [PATCH] test(web-runtime): table-test selector builder quoting and escaping --- pkg/spec/test/web-runtime.test.ts | 108 ++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/pkg/spec/test/web-runtime.test.ts b/pkg/spec/test/web-runtime.test.ts index 7c176eb..d01fbee 100644 --- a/pkg/spec/test/web-runtime.test.ts +++ b/pkg/spec/test/web-runtime.test.ts @@ -9,6 +9,44 @@ import { test } from "node:test"; // A 64-bit seed that loses precision as a JS Number must survive as a BigInt. process.env.SANDERLING_SEED = "9007199254740993"; +// cssEscape delegates to the browser's CSS.escape, absent in node. Install the +// WHATWG CSSOM escape algorithm so selector-builder tests exercise the real +// escaping production relies on, not a stub. +if (!(globalThis as { CSS?: unknown }).CSS) { + (globalThis as { CSS?: { escape(v: string): string } }).CSS = { + escape(value: string): string { + let out = ""; + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c === 0) { + out += "�"; + } else if ( + (c >= 0x1 && c <= 0x1f) || + c === 0x7f || + (i === 0 && c >= 0x30 && c <= 0x39) || + (i === 1 && c >= 0x30 && c <= 0x39 && value.charCodeAt(0) === 0x2d) + ) { + out += "\\" + c.toString(16) + " "; + } else if (i === 0 && c === 0x2d && value.length === 1) { + out += "\\" + value[i]; + } else if ( + c >= 0x80 || + c === 0x2d || + c === 0x5f || + (c >= 0x30 && c <= 0x39) || + (c >= 0x41 && c <= 0x5a) || + (c >= 0x61 && c <= 0x7a) + ) { + out += value[i]; + } else { + out += "\\" + value[i]; + } + } + return out; + }, + }; +} + const { __testing__ } = await import("../src/web-runtime.ts"); const { host } = __testing__; @@ -231,3 +269,73 @@ test("sanitize preserves arrays and nested plain values", () => { const result = sanitizeViaExtract({ items: [1, "two", { ok: true }] }); assert.deepEqual(result, { items: [1, "two", { ok: true }] }); }); + +// xpathStringLiteral builds XPath 1.0 string literals by hand (no escape +// syntax in XPath 1.0). A value carrying a quote that isn't wrapped or +// concat()-composed produces a malformed expression, so document.evaluate +// throws or, worse, matches the wrong node by truncating at the quote. +const { xpathStringLiteral } = __testing__; + +test("xpathStringLiteral table: quote handling stays well-formed", () => { + const cases: Array<[string, string]> = [ + ["plain", '"plain"'], + ['has"double', `'has"double'`], + ["has'single", `"has'single"`], + [`both"and'`, `concat("both", '"', "and'")`], + [`"`, `'"'`], + ]; + for (const [input, want] of cases) { + assert.equal(xpathStringLiteral(input), want, input); + } +}); + +// selectorFromString routes a "kind:value" prefix; text becomes an XPath +// equality, everything else a CSS attribute selector. A value containing a +// colon must not be re-split, and a quote in a text value must reach the +// well-formed XPath literal rather than corrupting the predicate. +const { selectorFromString, selectorFromObject } = __testing__; + +test("selectorFromString routes text to a normalize-space XPath", () => { + assert.deepEqual(selectorFromString("text:Hello"), { + xpath: `//*[normalize-space(text())="Hello"]`, + }); +}); + +test("selectorFromString keeps colons in the value intact", () => { + // Only the first colon splits kind from value; the rest is the value. + assert.deepEqual(selectorFromString("text:a:b:c"), { + xpath: `//*[normalize-space(text())="a:b:c"]`, + }); +}); + +test("selectorFromString text value with both quote kinds uses concat", () => { + assert.deepEqual(selectorFromString(`text:say "hi" o'clock`), { + xpath: `//*[normalize-space(text())=concat("say ", '"', "hi", '"', " o'clock")]`, + }); +}); + +test("selectorFromObject escapes attribute values to prevent injection", () => { + // A quote in an id value, left unescaped, would close the attribute selector + // early and match a different element. + assert.deepEqual(selectorFromObject({ id: 'a"]' }), { + css: `[id="a\\"\\]"]`, + }); +}); + +test("selectorFromObject maps known keys to their canonical attribute", () => { + assert.deepEqual(selectorFromObject({ testID: "submit" }), { + css: `[data-testid="submit"]`, + }); +}); + +test("selectorFromObject falls back to a literal attribute for unknown keys", () => { + assert.deepEqual(selectorFromObject({ "data-foo": "bar" }), { + css: `[data-foo="bar"]`, + }); +}); + +test("selectorFromObject text-only selector becomes an XPath", () => { + assert.deepEqual(selectorFromObject({ text: "Go" }), { + xpath: `//*[normalize-space(text())="Go"]`, + }); +});