fix(design): isolate the colour sampler's fillStyle with save and restore

js/useless-assignment-to-property on PR #625 (test-e2e-playwright design.js:163, bluesky test-design.js:178, decidim test-design.js:181): the black reset before each sampled colour was a real guard, since canvas keeps the old fillStyle for a colour it cannot parse. save/restore around the single write keeps that guard; the pixels are identical. One test per sampler against a canvas double; dropping the reset fails all three.

🎯 Fix: 95 %
💥 Break: 2 %
💸 Cost: –
⏱️ Time: –
🛡️ Security: ~100 %
🏆 Score: 93
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-11-05 17:12:53 +00:00
1 parent f816cd123b
commit 787f8d9a6d
7 files changed
+206 -3

No files matched your search

+2 -1
View File
@@ -160,9 +160,10 @@ async function measure(page, selector) {
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
const rgba = (value) => {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = "#000";
ctx.save();
ctx.fillStyle = value;
ctx.fillRect(0, 0, 1, 1);
ctx.restore();
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
return [r, g, b, a / 255];
};
@@ -175,9 +175,10 @@ async function contrast(locator) {
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
const rgba = (value) => {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = "#000";
ctx.save();
ctx.fillStyle = value;
ctx.fillRect(0, 0, 1, 1);
ctx.restore();
return [...ctx.getImageData(0, 0, 1, 1).data];
};
const luminance = ([r, g, b]) => {
@@ -178,9 +178,10 @@ async function assertTokenPixel(page, selector, property, token, label) {
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
const pixel = (value) => {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = "#000";
ctx.save();
ctx.fillStyle = value;
ctx.fillRect(0, 0, 1, 1);
ctx.restore();
return [...ctx.getImageData(0, 0, 1, 1).data].join(",");
};
const probe = document.createElement("span");
@@ -0,0 +1,38 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const PROJECT_ROOT = path.resolve(__dirname, "../../../../../..");
const { UNPARSEABLE_COLOUR, browserGlobals, styledElement } = require(
path.join(PROJECT_ROOT, "tests/unit/javascript/utils/canvas.js"),
);
const { loadInternals } = require(path.join(PROJECT_ROOT, "tests/unit/javascript/utils/internals.js"));
const globals = {};
const { measure } = loadInternals(
path.join(PROJECT_ROOT, "roles/test-e2e-playwright/files/design.js"),
["measure"],
{},
globals,
);
const page = { evaluate: async (callback, argument) => callback(argument) };
function sample(css, parentCss) {
const parent = parentCss ? styledElement(parentCss, null) : null;
Object.assign(globals, browserGlobals({}, { "#target": styledElement(css, parent) }));
return measure(page, "#target").then(structuredClone);
}
test("measure reads the text colour against the first opaque background up the tree", async () => {
assert.deepEqual(
await sample({ color: "rgb(10, 20, 30)", "background-color": "rgba(0, 0, 0, 0)" }, { "background-color": "#ffffff" }),
{ background: [255, 255, 255], color: [10, 20, 30] },
);
});
test("measure samples a colour canvas cannot parse as black, not as the background sampled before it", async () => {
assert.deepEqual(await sample({ color: UNPARSEABLE_COLOUR, "background-color": "rgb(255, 255, 255)" }), {
background: [255, 255, 255],
color: [0, 0, 0],
});
});
@@ -0,0 +1,31 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const PROJECT_ROOT = path.resolve(__dirname, "../../../../../../..");
const { UNPARSEABLE_COLOUR, browserGlobals, styledElement } = require(
path.join(PROJECT_ROOT, "tests/unit/javascript/utils/canvas.js"),
);
const { loadInternals } = require(path.join(PROJECT_ROOT, "tests/unit/javascript/utils/internals.js"));
const globals = browserGlobals({}, {});
const { contrast } = loadInternals(
path.join(PROJECT_ROOT, "roles/web-app-bluesky/files/playwright/test-design.js"),
["contrast"],
{},
globals,
);
function contrastOf(css) {
const element = styledElement(css, null);
return contrast({ evaluate: async (callback) => callback(element) });
}
test("contrast rates black text on white at 21 and white on white at 1", async () => {
assert.equal(await contrastOf({ color: "rgb(0, 0, 0)", "background-color": "rgb(255, 255, 255)" }), 21);
assert.equal(await contrastOf({ color: "rgb(255, 255, 255)", "background-color": "rgb(255, 255, 255)" }), 1);
});
test("contrast samples a text colour canvas cannot parse as black, not as the background sampled before it", async () => {
assert.equal(await contrastOf({ color: UNPARSEABLE_COLOUR, "background-color": "rgb(255, 255, 255)" }), 21);
});
@@ -0,0 +1,40 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const PROJECT_ROOT = path.resolve(__dirname, "../../../../../../..");
const { UNPARSEABLE_COLOUR, browserGlobals, styledElement } = require(
path.join(PROJECT_ROOT, "tests/unit/javascript/utils/canvas.js"),
);
const { loadInternals } = require(path.join(PROJECT_ROOT, "tests/unit/javascript/utils/internals.js"));
const TOKEN = "--design-link";
const expect = () => undefined;
expect.poll = (read, { message }) => ({
toBe: async (wanted) => assert.equal(await read(), wanted, message),
});
const registers = new Proxy(() => undefined, { get: () => () => undefined });
const globals = {};
const { assertTokenPixel } = loadInternals(
path.join(PROJECT_ROOT, "roles/web-app-decidim/files/playwright/test-design.js"),
["assertTokenPixel"],
{ "@playwright/test": { expect, test: registers } },
globals,
);
const page = { evaluate: async (callback, argument) => callback(argument) };
function linkRendersAs(color) {
Object.assign(globals, browserGlobals({ [TOKEN]: "rgb(255, 255, 255)" }, { a: styledElement({ color }, null) }));
return assertTokenPixel(page, "a", "color", TOKEN, "link");
}
test("assertTokenPixel accepts a colour that renders as the token and refuses another", async () => {
await linkRendersAs("#fff");
await assert.rejects(linkRendersAs("rgb(0, 0, 255)"), /link: a color must render as --design-link/);
});
test("assertTokenPixel refuses a colour canvas cannot parse instead of reading it as the token sampled before it", async () => {
await assert.rejects(linkRendersAs(UNPARSEABLE_COLOUR), { actual: "0,0,0,255 instead of 255,255,255,255" });
});
+91
View File
@@ -0,0 +1,91 @@
const UNPARSEABLE_COLOUR = "not-a-colour";
function parseColour(value) {
const text = String(value).trim().toLowerCase();
const hex = text.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
if (hex) {
const digits = hex[1].length === 3 ? [...hex[1]].map((digit) => digit + digit) : hex[1].match(/../g);
return [...digits.map((pair) => parseInt(pair, 16)), 255];
}
const rgb = text.match(/^rgba?\((\d+), (\d+), (\d+)(?:, ([\d.]+))?\)$/);
if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3]), Math.round(255 * Number(rgb[4] ?? 1))];
return null;
}
/**
* Canvas 2D context double.
*
* Returns:
* The context of a 1x1 canvas whose fillStyle starts black, ignores a colour it cannot parse, and is saved and restored with the drawing state.
*/
function canvasContext() {
const saved = [];
let fill = [0, 0, 0, 255];
let pixel = [0, 0, 0, 0];
return {
get fillStyle() {
return `rgba(${fill.join(", ")})`;
},
set fillStyle(value) {
fill = parseColour(value) ?? fill;
},
save: () => saved.push(fill),
restore: () => {
if (saved.length > 0) fill = saved.pop();
},
clearRect: () => {
pixel = [0, 0, 0, 0];
},
fillRect: () => {
pixel = [...fill];
},
getImageData: () => ({ data: Uint8ClampedArray.from(pixel) }),
};
}
/**
* Args:
* css: computed value per kebab-case property, e.g. `{ color: "rgb(0, 0, 0)" }`.
* parentElement: the element's parent, or null.
*
* Returns:
* A visible element double.
*/
function styledElement(css, parentElement) {
return { css, parentElement, getClientRects: () => [{}] };
}
/**
* Args:
* tokens: value per design token, e.g. `{ "--design-link": "rgb(0, 0, 255)" }`.
* elements: element per selector `document.querySelector` resolves.
*
* Returns:
* The `document` and `getComputedStyle` globals the colour samplers read; a probe styled with `var(token)` computes to the token's value.
*/
function browserGlobals(tokens, elements) {
const createElement = (tag) => {
if (tag === "canvas") return { getContext: () => canvasContext() };
const css = {};
return {
css,
style: { setProperty: (property, value) => (css[property] = tokens[value.slice(4, -1)]) },
remove: () => undefined,
};
};
return {
document: {
body: { appendChild: () => undefined },
createElement,
querySelector: (selector) => elements[selector] ?? null,
},
getComputedStyle: (element) => ({
visibility: "visible",
color: element.css.color,
backgroundColor: element.css["background-color"],
getPropertyValue: (property) => element.css[property] ?? "",
}),
};
}
module.exports = { UNPARSEABLE_COLOUR, browserGlobals, canvasContext, styledElement };