|
| 1 | +import AxeBuilder from "@axe-core/playwright"; |
| 2 | +import { expect, test, type Page } from "@playwright/test"; |
| 3 | + |
| 4 | +import { contrastSamples } from "./contrast-dom.js"; |
| 5 | +import { judgeContrast, type ContrastVerdict } from "./contrast.js"; |
| 6 | + |
| 7 | +// The stories sweep's contrast fallback on hand-built pages: each fixture is |
| 8 | +// text axe leaves "incomplete", and the fallback must not vouch for any of it |
| 9 | +// that renders below its floor. |
| 10 | + |
| 11 | +const DARK = |
| 12 | + "data:image/svg+xml;utf8," + |
| 13 | + encodeURIComponent( |
| 14 | + '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="#111"/></svg>', |
| 15 | + ); |
| 16 | + |
| 17 | +const page = (body: string) => |
| 18 | + `<!doctype html><html><body style="margin:0;font:14px/20px sans-serif;background:#fff">${body}</body></html>`; |
| 19 | + |
| 20 | +// Lines 2-3 overflow a white card onto black: #555 on #000 is ~2.8:1. |
| 21 | +const SPILL = `<div style="background:#000;padding:40px"><div style="background:#fff;height:22px;width:320px"><p id="t" style="margin:0;color:#555">On the card<br>Spills onto black<br>Also on black</p></div></div>`; |
| 22 | + |
| 23 | +// The same markup where the sampler has to scroll to it first. |
| 24 | +const placed = (markup: string) => |
| 25 | + [ |
| 26 | + ["in view", page(markup)], |
| 27 | + [ |
| 28 | + "below the fold", |
| 29 | + page( |
| 30 | + `<div style="height:1500px"></div>${markup}<div style="height:400px"></div>`, |
| 31 | + ), |
| 32 | + ], |
| 33 | + [ |
| 34 | + "inside nested scrollers", |
| 35 | + page( |
| 36 | + `<div style="height:300px;overflow:auto"><div style="height:500px"></div><div style="height:200px;overflow:auto"><div style="height:600px"></div>${markup}</div><div style="height:400px"></div></div>`, |
| 37 | + ), |
| 38 | + ], |
| 39 | + ] as const; |
| 40 | + |
| 41 | +// A chip with its own white ground, rotated, whose later lines spill onto |
| 42 | +// black: client rects bound the turned chip from outside. |
| 43 | +const chip = (degrees: number, align: string) => |
| 44 | + page( |
| 45 | + `<div style="background:#000;padding:200px 80px"><p id="t" style="margin:0;background:#fff;height:22px;width:500px;text-align:${align};color:#555;transform:rotate(${degrees}deg)">On the chip<br>Spills onto black<br>Also on black</p></div>`, |
| 46 | + ); |
| 47 | + |
| 48 | +/** The fallback's verdict on #t, and whether axe left #t incomplete. */ |
| 49 | +async function verdictFor( |
| 50 | + tab: Page, |
| 51 | + html: string, |
| 52 | +): Promise<{ incomplete: boolean; verdict: ContrastVerdict }> { |
| 53 | + await tab.setContent(html); |
| 54 | + const { incomplete } = await new AxeBuilder({ page: tab }) |
| 55 | + .withRules(["color-contrast"]) |
| 56 | + .analyze(); |
| 57 | + const [sample = null] = await contrastSamples(tab, ["#t"], []); |
| 58 | + return { |
| 59 | + incomplete: incomplete |
| 60 | + .flatMap((result) => result.nodes) |
| 61 | + .some((node) => node.target[0] === "#t"), |
| 62 | + verdict: judgeContrast(sample), |
| 63 | + }; |
| 64 | +} |
| 65 | + |
| 66 | +const NOT_VOUCHED = [ |
| 67 | + ...placed(SPILL).map( |
| 68 | + ([where, html]) => |
| 69 | + [ |
| 70 | + `text overflowing its card onto another surface, ${where}`, |
| 71 | + html, |
| 72 | + ] as const, |
| 73 | + ), |
| 74 | + ["a rotated chip's text spilling off it (10deg, left)", chip(10, "left")], |
| 75 | + ["a rotated chip's text spilling off it (-10deg, right)", chip(-10, "right")], |
| 76 | + // The glyphs are painted by the #ddd-#eee gradient, not by color. |
| 77 | + [ |
| 78 | + "gradient text through -webkit-text-fill-color", |
| 79 | + page( |
| 80 | + `<p id="t" style="color:#000;-webkit-text-fill-color:transparent;background-image:linear-gradient(90deg,#ddd,#eee);background-clip:text;-webkit-background-clip:text;display:inline-block">Generating 2 images</p>`, |
| 81 | + ), |
| 82 | + ], |
| 83 | + [ |
| 84 | + "a pointer-events:none wash over an image", |
| 85 | + page( |
| 86 | + `<div style="position:relative;width:300px;height:60px"><img src="${DARK}" style="position:absolute;inset:0;width:100%;height:100%"><div style="position:absolute;inset:0;background:rgba(255,255,255,.5);pointer-events:none"></div><span id="t" style="position:relative;color:#767676">Caption</span></div>`, |
| 87 | + ), |
| 88 | + ], |
| 89 | + [ |
| 90 | + "an ancestor ::before painting an image", |
| 91 | + page( |
| 92 | + `<style>.c{position:relative;width:300px;padding:10px}.c::before{content:"";position:absolute;inset:0;background:url('${DARK}');pointer-events:none}</style><div class="c"><span id="t" style="position:relative;color:#767676">Caption</span></div>`, |
| 93 | + ), |
| 94 | + ], |
| 95 | +] as const; |
| 96 | + |
| 97 | +for (const [name, html] of NOT_VOUCHED) |
| 98 | + test(`the contrast fallback does not pass ${name}`, async ({ page: tab }) => { |
| 99 | + const { incomplete, verdict } = await verdictFor(tab, html); |
| 100 | + expect(incomplete).toBe(true); |
| 101 | + expect(verdict).not.toMatchObject({ kind: "measured", pass: true }); |
| 102 | + }); |
| 103 | + |
| 104 | +// Controls: the same shapes with legible text still measure, so the |
| 105 | +// fixtures above fail for the reason they name. |
| 106 | +const VOUCHED = [ |
| 107 | + [ |
| 108 | + "text that stays on its card", |
| 109 | + page( |
| 110 | + `<div style="background:#000;padding:40px"><div style="background:#fff;height:62px;width:320px"><p id="t" style="margin:0;color:#555">On the card<br>Still on the card<br>Also on the card</p></div></div>`, |
| 111 | + ), |
| 112 | + ], |
| 113 | + [ |
| 114 | + "dark gradient text through -webkit-text-fill-color", |
| 115 | + page( |
| 116 | + `<p id="t" style="color:#fff;-webkit-text-fill-color:transparent;background-image:linear-gradient(90deg,#333,#000);background-clip:text;-webkit-background-clip:text;display:inline-block">Generating 2 images</p>`, |
| 117 | + ), |
| 118 | + ], |
| 119 | + [ |
| 120 | + "text under an empty stretched button", |
| 121 | + page( |
| 122 | + `<div style="position:relative;width:200px;background:#fff"><span id="t" style="color:#555">quarter-plan.pdf</span><button aria-label="Open" style="position:absolute;inset:0;background:none;border:0"></button></div>`, |
| 123 | + ), |
| 124 | + ], |
| 125 | +] as const; |
| 126 | + |
| 127 | +for (const [name, html] of VOUCHED) |
| 128 | + test(`the contrast fallback measures ${name}`, async ({ page: tab }) => { |
| 129 | + const { verdict } = await verdictFor(tab, html); |
| 130 | + expect(verdict).toMatchObject({ kind: "measured", pass: true }); |
| 131 | + }); |
| 132 | + |
| 133 | +// The verdict does not depend on where the text starts: every geometry read |
| 134 | +// happens where the sampler scrolled it to. |
| 135 | +const SAME_ANYWHERE = [ |
| 136 | + ["text overflowing its card", SPILL], |
| 137 | + [ |
| 138 | + "text on its own faint wash", |
| 139 | + `<div style="background:#fff;padding:20px"><span id="t" style="color:#767676;background:rgba(0,0,0,.02)">Caption on a wash</span></div>`, |
| 140 | + ], |
| 141 | + [ |
| 142 | + "text that stays on its card", |
| 143 | + `<div style="background:#000;padding:40px"><div style="background:#fff;height:62px;width:320px"><p id="t" style="margin:0;color:#555">On the card<br>Still on the card<br>Also on the card</p></div></div>`, |
| 144 | + ], |
| 145 | +] as const; |
| 146 | + |
| 147 | +for (const [name, markup] of SAME_ANYWHERE) |
| 148 | + test(`the contrast fallback judges ${name} the same wherever it starts`, async ({ |
| 149 | + page: tab, |
| 150 | + }) => { |
| 151 | + const verdicts: ContrastVerdict[] = []; |
| 152 | + for (const [, html] of placed(markup)) |
| 153 | + verdicts.push((await verdictFor(tab, html)).verdict); |
| 154 | + const [inView] = verdicts; |
| 155 | + expect(inView?.kind).toBe("measured"); |
| 156 | + for (const verdict of verdicts) expect(verdict).toEqual(inView); |
| 157 | + }); |
0 commit comments