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);
});
});