|
1 | | -import { describe, it, expect, vi, afterEach } from "vitest"; |
| 1 | +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; |
2 | 2 | import { createPickerModule } from "./picker"; |
3 | 3 |
|
| 4 | +// jsdom does not implement CSS.escape — polyfill a compact spec-adjacent |
| 5 | +// version. Parallel (simpler) polyfills already live in compositionLoader.test.ts |
| 6 | +// / startResolver.test.ts, but they don't handle the leading-digit case this |
| 7 | +// test needs. Each test file runs in an isolated environment, so we duplicate |
| 8 | +// rather than import. |
| 9 | +beforeAll(() => { |
| 10 | + const css = globalThis.CSS as { escape?: (input: string) => string } | undefined; |
| 11 | + if (!css || typeof css.escape !== "function") { |
| 12 | + (globalThis as { CSS?: { escape: (input: string) => string } }).CSS = { |
| 13 | + ...(css ?? {}), |
| 14 | + escape: (value: string) => { |
| 15 | + // Non-word chars get a leading backslash (spec-adjacent). |
| 16 | + const escaped = value.replace(/([^\w-])/g, "\\$1"); |
| 17 | + // A leading digit must be encoded as `\<hex> ` (space terminator) per CSS spec. |
| 18 | + const first = value.charCodeAt(0); |
| 19 | + if (first >= 48 && first <= 57) { |
| 20 | + return `\\${first.toString(16)} ${escaped.slice(1)}`; |
| 21 | + } |
| 22 | + return escaped; |
| 23 | + }, |
| 24 | + }; |
| 25 | + } |
| 26 | +}); |
| 27 | + |
4 | 28 | function createMockPostMessage() { |
5 | 29 | return vi.fn(); |
6 | 30 | } |
@@ -181,4 +205,52 @@ describe("createPickerModule", () => { |
181 | 205 | expect(document.body.classList.contains("__hf-pick-active")).toBe(true); |
182 | 206 | }); |
183 | 207 | }); |
| 208 | + |
| 209 | + describe("buildElementSelector escapes digit-leading ids", () => { |
| 210 | + it('produces a CSS-valid selector for id="0" and picks the element back', () => { |
| 211 | + // Regression: a user's HTML with id="0" (or any digit-leading id) used |
| 212 | + // to produce the raw selector "#0", which is invalid per the CSS spec — |
| 213 | + // downstream querySelector calls threw SyntaxError. buildElementSelector |
| 214 | + // now CSS.escapes the id. |
| 215 | + const picker = createPickerModule({ postMessage: createMockPostMessage() }); |
| 216 | + picker.installPickerApi(); |
| 217 | + const el = document.createElement("div"); |
| 218 | + el.id = "0"; |
| 219 | + Object.assign(el.style, { |
| 220 | + position: "absolute", |
| 221 | + left: "0px", |
| 222 | + top: "0px", |
| 223 | + width: "40px", |
| 224 | + height: "40px", |
| 225 | + }); |
| 226 | + document.body.appendChild(el); |
| 227 | + |
| 228 | + // Force elementsFromPoint to hit our div so we exercise the real code |
| 229 | + // path that calls buildElementSelector via extractElementInfo. |
| 230 | + const originalElementsFromPoint = document.elementsFromPoint; |
| 231 | + Object.defineProperty(document, "elementsFromPoint", { |
| 232 | + configurable: true, |
| 233 | + value: () => [el], |
| 234 | + }); |
| 235 | + try { |
| 236 | + const api = ( |
| 237 | + window as { |
| 238 | + __HF_PICKER_API?: { |
| 239 | + pickAtPoint?: (x: number, y: number) => { selector: string } | null; |
| 240 | + }; |
| 241 | + } |
| 242 | + ).__HF_PICKER_API; |
| 243 | + const picked = api?.pickAtPoint?.(10, 10); |
| 244 | + expect(picked?.selector).toBe("#\\30 "); |
| 245 | + // And the round trip must find the element back through querySelector. |
| 246 | + expect(() => document.querySelector(picked?.selector ?? "")).not.toThrow(); |
| 247 | + expect(document.querySelector(picked?.selector ?? "")).toBe(el); |
| 248 | + } finally { |
| 249 | + Object.defineProperty(document, "elementsFromPoint", { |
| 250 | + configurable: true, |
| 251 | + value: originalElementsFromPoint, |
| 252 | + }); |
| 253 | + } |
| 254 | + }); |
| 255 | + }); |
184 | 256 | }); |
0 commit comments