diff --git a/packages/cli/src/commands/contrast-audit.browser.js b/packages/cli/src/commands/contrast-audit.browser.js index a9b780acb8..854b0b6458 100644 --- a/packages/cli/src/commands/contrast-audit.browser.js +++ b/packages/cli/src/commands/contrast-audit.browser.js @@ -286,7 +286,7 @@ window.__contrastAuditPrepare = function () { function isClippedAway(el, rect) { if (typeof document.elementFromPoint !== "function") return false; if (!hasClipPath(el)) return false; - return !paintsAnyProbePoint(el, rect); + return !paintsAnyProbePoint(el, rect, true); } function isIntentionallyOccluded(el, rect) { diff --git a/packages/cli/src/commands/contrast-occlusion.browser.test.ts b/packages/cli/src/commands/contrast-occlusion.browser.test.ts index 9bb2ec0141..521c95abe6 100644 --- a/packages/cli/src/commands/contrast-occlusion.browser.test.ts +++ b/packages/cli/src/commands/contrast-occlusion.browser.test.ts @@ -206,4 +206,29 @@ describe("contrast audit beneath composition boxes", () => { installStack(["overlay", "root"]); expect(prepare()).toEqual([]); }); + + it.each(["inset(0)", "inset(0 50% 0 0)"])( + "audits visible text with clip-path %s beneath a transparent host", + (clipPath) => { + element("headline").style.clipPath = clipPath; + expect(prepare()).toContainEqual(expect.objectContaining({ selector: "#headline" })); + }, + ); + + it("audits text clipped by an ancestor beneath transparent inner roots", () => { + element("headline").removeAttribute("data-layout-allow-occlusion"); + element("root").style.clipPath = "inset(0)"; + const inner = document.createElement("div"); + inner.id = "inner"; + inner.dataset.hfInnerRoot = "true"; + element("overlay").appendChild(inner); + installStack(["inner", "overlay", "headline", "root"]); + expect(prepare()).toContainEqual(expect.objectContaining({ selector: "#headline" })); + }); + + it("keeps clipped text behind a painted host out of contrast candidates", () => { + element("headline").style.clipPath = "inset(0)"; + element("overlay").style.backgroundColor = "white"; + expect(prepare()).toEqual([]); + }); }); diff --git a/packages/cli/src/commands/contrast-occlusion.chromium.test.ts b/packages/cli/src/commands/contrast-occlusion.chromium.test.ts new file mode 100644 index 0000000000..6c78ab67e7 --- /dev/null +++ b/packages/cli/src/commands/contrast-occlusion.chromium.test.ts @@ -0,0 +1,88 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import puppeteer, { type Browser } from "puppeteer-core"; + +declare global { + interface Window { + __contrastAuditPrepare(): { selector: string }[]; + } +} + +const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH; +const script = readFileSync(new URL("./contrast-audit.browser.js", import.meta.url), "utf8"); + +describe.runIf(executablePath)("clipped-text contrast candidates in Chromium", () => { + let browser: Browser; + + beforeAll(async () => { + browser = await puppeteer.launch({ executablePath, args: ["--no-sandbox"] }); + }); + + afterAll(async () => { + await browser?.close(); + }); + + async function audit( + clipPath: string, + options: { ancestor?: boolean; nested?: boolean; painted?: boolean } = {}, + ) { + const page = await browser.newPage(); + try { + await page.setViewport({ width: 640, height: 360, deviceScaleFactor: 1 }); + const clip = `clip-path:${clipPath}`; + const overlay = options.nested + ? '
' + : ""; + await page.setContent(` +
+
+
Visible copy
+
+
${overlay}
+
+ `); + const image = await page.screenshot(); + await page.addScriptTag({ content: script }); + const selectors = await page.evaluate(() => + window.__contrastAuditPrepare().map((entry) => entry.selector), + ); + return { image, selectors }; + } finally { + await page.close(); + } + } + + it("audits an unchanged visible frame when a clip-path is added", async () => { + const normal = await audit("none"); + const clipped = await audit("inset(0)"); + expect(clipped.image).toEqual(normal.image); + expect(normal.selectors).toContain("#headline"); + expect(clipped.selectors).toContain("#headline"); + }); + + it("audits partially revealed text beneath a transparent composition host", async () => { + const partial = await audit("inset(0 50% 0 0)"); + const hidden = await audit("inset(0 100% 0 0)"); + expect(partial.image).not.toEqual(hidden.image); + expect(partial.selectors).toContain("#headline"); + expect(hidden.selectors).not.toContain("#headline"); + }); + + it("walks through nested transparent roots for an ancestor clip-path", async () => { + const clipped = await audit("inset(0)", { ancestor: true, nested: true }); + expect(clipped.selectors).toContain("#headline"); + }); + + it.each([{ ancestor: false }, { ancestor: true }])( + "keeps fully clipped text out of contrast candidates with ancestor=$ancestor", + async (options) => { + const hidden = await audit("inset(0 100% 0 0)", options); + expect(hidden.selectors).not.toContain("#headline"); + }, + ); + + it("keeps text behind a painted composition host out of contrast candidates", async () => { + const covered = await audit("inset(0)", { painted: true }); + expect(covered.selectors).not.toContain("#headline"); + }); +});