import { cleanup, render, waitFor } from "@testing-library/react"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import SwatchGroup, { type SwatchData } from "./SwatchGroup"; /** * Regression: image swatches used `backgroundImage: url(${imageUrl})` with no * quotes. Any URL containing `(`/`)` (SVG data URIs with `fill="url(#g)"`, * uploads named `swatch (1).png`) made the declaration invalid and Chromium * dropped it, rendering a blank swatch. The URL must now be emitted as a * single double-quoted CSS string that round-trips to the original value. * * jsdom's cssstyle rejects any `)` or space inside url() even when quoted, so * reading `style.backgroundImage` back would be empty for exactly the inputs * under test. We therefore record the raw value React assigns to the CSSOM * setter, which is the string a real browser parses. */ const svgWithGradientFill = ''; const images: Record = { svg: `data:image/svg+xml,${encodeURIComponent(svgWithGradientFill)}`, parens: "https://shop.example/wp-content/uploads/2026/09/swatch (1).png", quotes: 'https://shop.example/uploads/we"ird\\name.png', }; const swatches: Record = Object.fromEntries( Object.entries(images).map(([value, image]) => [ value, { type: "image", image, color: "#000000", label: value }, ]), ); const rawBackgroundImage = new WeakMap(); let restoreSetter: (() => void) | undefined; beforeAll(() => { let proto: object | null = window.CSSStyleDeclaration.prototype; let descriptor: PropertyDescriptor | undefined; while (proto && !descriptor) { descriptor = Object.getOwnPropertyDescriptor(proto, "backgroundImage"); if (!descriptor) proto = Object.getPrototypeOf(proto); } if (!proto || !descriptor?.set) { throw new Error("CSSStyleDeclaration.backgroundImage setter not found"); } const target = proto; const original = descriptor; Object.defineProperty(target, "backgroundImage", { ...original, set(this: CSSStyleDeclaration, value: string) { rawBackgroundImage.set(this, String(value)); original.set!.call(this, value); }, }); restoreSetter = () => Object.defineProperty(target, "backgroundImage", original); }); afterAll(() => { restoreSetter?.(); }); function createSelect(): HTMLSelectElement { const select = document.createElement("select"); select.id = "attribute_pa_finish"; select.name = "attribute_pa_finish"; const label = document.createElement("label"); label.htmlFor = select.id; label.textContent = "Finish"; for (const value of ["", ...Object.keys(images)]) { const option = document.createElement("option"); option.value = value; option.textContent = value || "Choose"; select.append(option); } document.body.append(label, select); return select; } function unescapeCssString(value: string): string { return value.replace( /\\([0-9a-fA-F]{1,6}) ?|\\(.)/gs, (_match, hex: string | undefined, char: string | undefined) => hex ? String.fromCodePoint(parseInt(hex, 16)) : (char as string), ); } function getImageLayer( container: HTMLElement, value: string, ): { layer: HTMLElement; declared: string } { const radio = container.querySelector( `[data-b3-wvs-radio-value="${value}"]`, ); if (!radio) throw new Error(`Missing radio for ${value}`); const layer = Array.from(radio.querySelectorAll("span")).find( (span) => rawBackgroundImage.has(span.style), ); if (!layer) throw new Error(`No background-image layer for ${value}`); return { layer, declared: rawBackgroundImage.get(layer.style)! }; } async function renderGroup() { const select = createSelect(); const { container } = render( , ); await waitFor(() => expect(container.querySelectorAll('[role="radio"]')).toHaveLength(3), ); return container; } afterEach(() => { cleanup(); document.body.innerHTML = ""; }); describe("SwatchGroup image swatch background", () => { it.each(Object.keys(images))( "emits a single quoted url() that round-trips the %s URL", async (value) => { const container = await renderGroup(); const { layer, declared } = getImageLayer(container, value); // Exactly one url() whose payload is a well-formed CSS string: no raw // quote, backslash, or control char inside, so it cannot be cut short. const match = // eslint-disable-next-line no-control-regex /^url\("((?:[^"\\\u0000-\u001f\u007f]|\\[^\n\r\f])*)"\)$/s.exec( declared, ); expect(match, `unquoted or malformed url(): ${declared}`).not.toBeNull(); // The browser resolves the escaped payload back to the stored URL, // byte for byte. Nothing was re-encoded or stripped. expect(unescapeCssString(match![1])).toBe(images[value]); // Sibling background properties are present on the same layer. expect(layer.style.backgroundRepeat).toBe("no-repeat"); expect(layer.style.backgroundPosition).toBe("center"); }, ); it("jsdom accepts the quoted form when the URL has no parentheses", async () => { // Sanity check that the recorded raw value is what the CSSOM stored for // an input cssstyle can parse, so the hook is observing the real setter. const container = await renderGroup(); const { layer, declared } = getImageLayer(container, "quotes"); expect(layer.style.backgroundImage).toBe(declared); }); it("keeps url(#g) inside the SVG data URI without letting it close the url()", async () => { const container = await renderGroup(); const { declared } = getImageLayer(container, "svg"); expect(images.svg).toContain("url(%23g)"); expect(declared).toContain("url(%23g)"); expect(declared.startsWith('url("data:image/svg+xml,')).toBe(true); // The first ")" belongs to the inner url(%23g), not the outer url(). expect(declared.indexOf(")")).toBeGreaterThan(declared.indexOf("url(%23g")); expect(declared.endsWith('")')).toBe(true); // And the unquoted shape that Chromium rejects is gone. expect(declared.startsWith("url(data:")).toBe(false); }); });