mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-10 12:47:04 +00:00
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:
1 parent
f816cd123b
commit
787f8d9a6d
7 files changed
+206
-3
No files matched your search
@@ -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" });
|
||||
});
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user