@josueavalosjim/taste-check 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -23
- package/bin/taste-check.mjs +18 -12
- package/package.json +3 -2
- package/schema/config.schema.json +99 -0
- package/src/cdp.mjs +252 -0
- package/src/color.mjs +16 -0
- package/src/config.mjs +81 -3
- package/src/index.mjs +12 -0
- package/src/report.mjs +4 -2
- package/src/runtime.mjs +161 -0
package/README.md
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
# taste-check
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
contrast ratios from your own custom properties, and flags class names and
|
|
5
|
-
literal values that are not on your own approved list.
|
|
3
|
+
Design review in CI, with a line down the middle of it.
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
Some of design review is measurable. Contrast ratios, values that should have
|
|
6
|
+
been tokens, class names nobody approved. taste-check measures those and fails
|
|
7
|
+
your build on them, either from your token file or from a page it renders in a
|
|
8
|
+
real browser.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
The rest is judgment, and it cannot be measured. For that it sends your
|
|
11
|
+
screenshots and your checklist to a model, and reports what comes back as an
|
|
12
|
+
opinion rather than a result.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Keeping those two apart is the whole design. A tool that blurs them either
|
|
15
|
+
blocks your pipeline on a coin flip or quietly downgrades a real failure to a
|
|
16
|
+
suggestion.
|
|
15
17
|
|
|
16
18
|
```bash
|
|
17
19
|
npm i -D @josueavalosjim/taste-check
|
|
18
20
|
```
|
|
19
21
|
|
|
22
|
+
```bash
|
|
23
|
+
npx @josueavalosjim/taste-check
|
|
24
|
+
```
|
|
25
|
+
|
|
20
26
|
```
|
|
21
27
|
contrast ok, 10 pairs across 2 themes
|
|
22
28
|
ok 17.76:1 needs 4.5 light --text-strong on --surface body text
|
|
@@ -28,20 +34,37 @@ treatments FAILED
|
|
|
28
34
|
FAIL src/Promo.jsx:8 inline value "#ff0055" on <a> is a one-off. Use a token, or add it to approvedValues.
|
|
29
35
|
```
|
|
30
36
|
|
|
37
|
+
```bash
|
|
38
|
+
npx @josueavalosjim/taste-check judge
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
judge ok, 5 lines against 2 screenshots, 1 to read
|
|
43
|
+
NOTE fail Nothing important is cut off at the edge: the third card runs
|
|
44
|
+
past the right edge of the frame at roughly x=798 of 800.
|
|
45
|
+
```
|
|
46
|
+
|
|
31
47
|
Zero runtime dependencies. Node 22 or newer.
|
|
32
48
|
|
|
33
49
|
## Why this exists
|
|
34
50
|
|
|
35
|
-
|
|
36
|
-
an element fails 4.5:1
|
|
37
|
-
while your captions answer to 4.5:1
|
|
38
|
-
spec's.
|
|
51
|
+
Linters have a gate and no taste. They enforce what someone could write down
|
|
52
|
+
as a rule, which is why they can tell you an element fails 4.5:1 but not that
|
|
53
|
+
your borders answer to 3:1 while your captions answer to 4.5:1. That
|
|
54
|
+
distinction is yours, not the spec's.
|
|
39
55
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
The newer AI design reviewers have taste and no gate. They will tell you what
|
|
57
|
+
is wrong with a screen, in prose, in a chat window, and some of them arrive
|
|
58
|
+
with a few hundred opinions already loaded about what good looks like.
|
|
59
|
+
|
|
60
|
+
Both halves are useful and they need to stay separable. Run the same model
|
|
61
|
+
twice over the same unchanged screen and you can get two different answers,
|
|
62
|
+
which is fine for advice and disqualifying for a build gate. So the measured
|
|
63
|
+
half here gates, the judged half does not, and the tool will not let you
|
|
64
|
+
confuse one for the other by accident.
|
|
65
|
+
|
|
66
|
+
It also ships no design rules of its own. Not a palette, not a class list, not
|
|
67
|
+
a contrast floor, not a checklist. You supply all of it.
|
|
45
68
|
|
|
46
69
|
## What counts as a failure
|
|
47
70
|
|
|
@@ -58,6 +81,12 @@ Each of these exits 1 rather than passing quietly:
|
|
|
58
81
|
A check that cannot fail is worse than no check, because it goes green and gets
|
|
59
82
|
quoted as evidence.
|
|
60
83
|
|
|
84
|
+
The judge is held to the same rule from the other side. Its verdicts never
|
|
85
|
+
affect the exit code by default, but a judge that could not run does: no
|
|
86
|
+
screenshots, a command that died, a reply that was not JSON or that skipped a
|
|
87
|
+
checklist line. "Did not run" and "found nothing" must not print the same
|
|
88
|
+
thing.
|
|
89
|
+
|
|
61
90
|
## The contrast check
|
|
62
91
|
|
|
63
92
|
Give it your custom properties, describe your themes, and list what must clear
|
|
@@ -152,6 +181,60 @@ direction to be wrong in.
|
|
|
152
181
|
Template literal holes are read into rather than blanked, so a class written
|
|
153
182
|
inside `` `card ${on ? 'card--on' : ''}` `` is seen.
|
|
154
183
|
|
|
184
|
+
## Measuring the rendered page
|
|
185
|
+
|
|
186
|
+
The contrast check above reads your token file, which tells you what a colour
|
|
187
|
+
is declared to be. `taste-check runtime` opens the page and reads what is
|
|
188
|
+
actually painted.
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
{
|
|
192
|
+
"runtime": {
|
|
193
|
+
"url": "http://localhost:3000",
|
|
194
|
+
"states": [
|
|
195
|
+
{ "name": "light" },
|
|
196
|
+
{ "name": "dark", "before": "localStorage.setItem('theme', 'dark')" }
|
|
197
|
+
],
|
|
198
|
+
"targets": [
|
|
199
|
+
{ "selector": ".caption", "prop": "color", "min": 4.5 },
|
|
200
|
+
{ "selector": ".panel", "prop": "borderTopColor", "min": 3.0 },
|
|
201
|
+
{ "selector": ".row.is-selected", "prop": "backgroundColor", "min": 1.25, "againstParent": true }
|
|
202
|
+
]
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
taste-check runtime
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
It launches a headless Chromium, or connects to one you already have if you
|
|
212
|
+
give it an `endpoint`, and it never closes a browser it did not start. There is
|
|
213
|
+
no dependency for this. The Chrome DevTools Protocol is JSON over the WebSocket
|
|
214
|
+
Node already ships, and the part of it needed here is two methods.
|
|
215
|
+
|
|
216
|
+
Two things this does that reading tokens cannot.
|
|
217
|
+
|
|
218
|
+
**It composites the whole background stack.** White text on a 75% black scrim
|
|
219
|
+
over a near-white page measures 10.57:1. Stop at the first opaque ancestor, the
|
|
220
|
+
way the check this was ported from did, and you measure the white against the
|
|
221
|
+
page instead and get 1.04:1. That is a failure the design has not earned, and
|
|
222
|
+
with the colours the other way round the same shortcut hands you a pass it has
|
|
223
|
+
not earned either.
|
|
224
|
+
|
|
225
|
+
**It can measure a state.** `before` runs on every navigation ahead of the
|
|
226
|
+
page's own scripts, which is where a theme belongs because the page reads it at
|
|
227
|
+
boot. `after` runs once there is a document, for opening a panel or focusing a
|
|
228
|
+
field. Each state's setup is removed before the next one, so they cannot leak.
|
|
229
|
+
|
|
230
|
+
Use `againstParent` when the thing being measured is a fill rather than a
|
|
231
|
+
foreground. A selected row with its own background measured against itself
|
|
232
|
+
scores 1.00, which is a pass that means nothing.
|
|
233
|
+
|
|
234
|
+
A selector that matches nothing is a failure. So is a border colour on an edge
|
|
235
|
+
with no width: you asked for the contrast of something that is not being drawn,
|
|
236
|
+
and the question is wrong rather than the answer being zero.
|
|
237
|
+
|
|
155
238
|
## The judge
|
|
156
239
|
|
|
157
240
|
Everything above measures. This asks the question a measurement cannot: not
|
|
@@ -238,9 +321,15 @@ from anywhere.
|
|
|
238
321
|
| `judge.shotCommand` | Optional command run first to produce those screenshots. |
|
|
239
322
|
| `judge.command` | The model command. Prompt on stdin, image paths as arguments. |
|
|
240
323
|
| `judge.failOn` | `"never"` (default) or `"fail"`. Whether a verdict blocks. |
|
|
324
|
+
| `runtime.url` | The page to measure. A `file://` URL works. |
|
|
325
|
+
| `runtime.endpoint` | An existing CDP endpoint. Given one, taste-check connects rather than launching, and never closes a browser it did not start. |
|
|
326
|
+
| `runtime.browserPath` | Path to a Chromium. Falls back to `CHROME_PATH`, then the usual locations. |
|
|
327
|
+
| `runtime.states[]` | `before` runs ahead of the page's scripts, `after` once loaded, `waitFor` is a selector. |
|
|
328
|
+
| `runtime.targets[]` | `selector`, `prop`, `min`, and `againstParent` when measuring a fill. |
|
|
241
329
|
|
|
242
330
|
```
|
|
243
|
-
taste-check [options] Run the deterministic checks
|
|
331
|
+
taste-check [options] Run the deterministic checks over your files
|
|
332
|
+
taste-check runtime [options] Measure contrast on a rendered page
|
|
244
333
|
taste-check judge [options] Ask a fresh-eyes judge about your screenshots
|
|
245
334
|
|
|
246
335
|
-c, --config <path> Config file (default: tastecheck.config.json)
|
|
@@ -252,6 +341,33 @@ taste-check judge [options] Ask a fresh-eyes judge about your screenshots
|
|
|
252
341
|
Exit code is 1 if any check fails, 0 if every check ran and passed. The judge
|
|
253
342
|
plays by the rules in its own section above.
|
|
254
343
|
|
|
344
|
+
## Where it sits next to other tools
|
|
345
|
+
|
|
346
|
+
This is a small tool with a narrow claim, and several of these are better than
|
|
347
|
+
it at the thing they do. Reach for them.
|
|
348
|
+
|
|
349
|
+
**axe, pa11y.** They run against a real rendered page and catch far more than
|
|
350
|
+
contrast. taste-check checks pairs you declare in a config, before a page
|
|
351
|
+
exists and without a browser. Use both. If you only run one accessibility
|
|
352
|
+
tool, run axe.
|
|
353
|
+
|
|
354
|
+
**stylelint, eslint.** General code quality, with an enormous rule ecosystem.
|
|
355
|
+
Nothing here replaces them.
|
|
356
|
+
|
|
357
|
+
**@lapidist/design-lint.** More thorough than taste-check on the token and
|
|
358
|
+
component side: it parses properly rather than scanning, knows about
|
|
359
|
+
frameworks, autofixes, and manages deprecations. If enforcing tokens in code is
|
|
360
|
+
the whole of your problem, it is the better fit.
|
|
361
|
+
|
|
362
|
+
**Checklist Design and similar agent skills.** They arrive with a hundred or
|
|
363
|
+
more published checklists and review conversationally. If you want good
|
|
364
|
+
opinions supplied, take theirs. taste-check supplies none on purpose and runs
|
|
365
|
+
in CI with an exit code instead.
|
|
366
|
+
|
|
367
|
+
What is left, and the reason this exists: nothing above draws a line between
|
|
368
|
+
the part that can gate a build and the part that cannot. The linters have no
|
|
369
|
+
judgment, the judges have no gate.
|
|
370
|
+
|
|
255
371
|
## What this does not do
|
|
256
372
|
|
|
257
373
|
Read this before trusting a green run.
|
|
@@ -287,13 +403,16 @@ invisible to it.
|
|
|
287
403
|
|
|
288
404
|
Not built. Written down so the shape is clear.
|
|
289
405
|
|
|
290
|
-
**A runtime mode**, closing the gap named above by measuring `getComputedStyle`
|
|
291
|
-
in a real browser, as an optional peer dependency so the core stays free of one.
|
|
292
|
-
|
|
293
406
|
**YAML configs**, once there is a reason to take on a parser.
|
|
294
407
|
|
|
408
|
+
**SARIF output**, so findings land in a code scanning tab rather than only in
|
|
409
|
+
a log.
|
|
410
|
+
|
|
411
|
+
**A way to run the judge from an agent skill**, not only from a shell.
|
|
412
|
+
|
|
295
413
|
**`lab()` and `lch()`**, which need the D50 white point and a chromatic
|
|
296
|
-
adaptation step that `oklch()` does not.
|
|
414
|
+
adaptation step that `oklch()` does not. Completeness rather than reach, so
|
|
415
|
+
it sits behind the others. Worth doing the same way when it happens: derive it,
|
|
297
416
|
then check every case against a browser rather than trusting the matrices.
|
|
298
417
|
|
|
299
418
|
## Development
|
package/bin/taste-check.mjs
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
* not a quiet skip.
|
|
9
9
|
*/
|
|
10
10
|
import { load } from '../src/config.mjs';
|
|
11
|
-
import { judge, run } from '../src/index.mjs';
|
|
11
|
+
import { judge, run, runtime } from '../src/index.mjs';
|
|
12
12
|
import { failed, toJson, toText } from '../src/report.mjs';
|
|
13
13
|
|
|
14
14
|
const USAGE = `taste-check
|
|
15
15
|
|
|
16
|
-
taste-check [options] Run the deterministic checks
|
|
16
|
+
taste-check [options] Run the deterministic checks over your files
|
|
17
|
+
taste-check runtime [options] Measure contrast on a rendered page
|
|
17
18
|
taste-check judge [options] Ask a fresh-eyes judge about your screenshots
|
|
18
19
|
|
|
19
20
|
Options:
|
|
@@ -25,6 +26,11 @@ Options:
|
|
|
25
26
|
|
|
26
27
|
Exit code is 1 if any check fails, 0 if every check ran and passed.
|
|
27
28
|
|
|
29
|
+
runtime is a separate command because it needs a browser and a server that
|
|
30
|
+
is already up. It measures what is actually painted, compositing every
|
|
31
|
+
background layer behind an element rather than stopping at the first opaque
|
|
32
|
+
one, and it can put the page into a state first.
|
|
33
|
+
|
|
28
34
|
The judge is a separate command because it runs a model, and a model's
|
|
29
35
|
verdict is not reproducible. Its verdicts print as notes and do not affect
|
|
30
36
|
the exit code unless judge.failOn is set to "fail". Whether the judge ran
|
|
@@ -35,8 +41,8 @@ function parseArgs(argv) {
|
|
|
35
41
|
const options = { config: 'tastecheck.config.json', only: null, json: false, command: 'check' };
|
|
36
42
|
// One positional, and only in first position, so a stray argument is an
|
|
37
43
|
// error rather than something silently ignored.
|
|
38
|
-
if (argv[0] === 'judge') {
|
|
39
|
-
options.command =
|
|
44
|
+
if (argv[0] === 'judge' || argv[0] === 'runtime') {
|
|
45
|
+
options.command = argv[0];
|
|
40
46
|
argv = argv.slice(1);
|
|
41
47
|
}
|
|
42
48
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -95,18 +101,18 @@ if (!loaded.ok) {
|
|
|
95
101
|
process.exit(1);
|
|
96
102
|
}
|
|
97
103
|
|
|
98
|
-
if (options.command
|
|
99
|
-
die(
|
|
104
|
+
if (options.command !== 'check' && options.only) {
|
|
105
|
+
die(`--only applies to the deterministic checks, not to ${options.command}`);
|
|
100
106
|
}
|
|
101
107
|
|
|
102
|
-
if (options.command
|
|
103
|
-
die(`${options.config} defines no "
|
|
108
|
+
if (options.command !== 'check' && !loaded.config[options.command]) {
|
|
109
|
+
die(`${options.config} defines no "${options.command}" block.`);
|
|
104
110
|
}
|
|
105
111
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
let results;
|
|
113
|
+
if (options.command === 'judge') results = judge(loaded.config, loaded.dir);
|
|
114
|
+
else if (options.command === 'runtime') results = await runtime(loaded.config, loaded.dir);
|
|
115
|
+
else results = run(loaded.config, loaded.dir, { only: options.only });
|
|
110
116
|
|
|
111
117
|
if (!results.length) {
|
|
112
118
|
die(`nothing to run. ${options.config} defines no ${options.only ?? 'contrast or treatments'} check.`);
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@josueavalosjim/taste-check",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Design review in CI with a line down the middle: measured checks that gate the build (WCAG contrast from your tokens or from a real rendered page, one-off values in your markup) and a fresh-eyes model judge whose verdicts stay advisory. Zero dependencies. Ships no design rules of its own.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"accessibility",
|
|
7
7
|
"cli",
|
|
8
8
|
"contrast",
|
|
9
|
+
"design-review",
|
|
9
10
|
"design-system",
|
|
10
11
|
"design-tokens",
|
|
11
12
|
"lint",
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
"treatments"
|
|
18
18
|
]
|
|
19
19
|
},
|
|
20
|
+
{
|
|
21
|
+
"required": [
|
|
22
|
+
"runtime"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
20
25
|
{
|
|
21
26
|
"required": [
|
|
22
27
|
"judge"
|
|
@@ -224,6 +229,100 @@
|
|
|
224
229
|
"description": "Whether a \"fail\" verdict affects the exit code. Defaults to never: a model's verdict is not reproducible, so it does not gate a build unless you decide it should. Independent of this, a judge that could not run always exits 1."
|
|
225
230
|
}
|
|
226
231
|
}
|
|
232
|
+
},
|
|
233
|
+
"runtime": {
|
|
234
|
+
"type": "object",
|
|
235
|
+
"description": "Contrast measured on a rendered page rather than derived from a token file. Run with `taste-check runtime`. Needs a Chromium and a server that is already up.",
|
|
236
|
+
"additionalProperties": false,
|
|
237
|
+
"required": [
|
|
238
|
+
"url",
|
|
239
|
+
"targets"
|
|
240
|
+
],
|
|
241
|
+
"properties": {
|
|
242
|
+
"url": {
|
|
243
|
+
"type": "string",
|
|
244
|
+
"description": "The page to measure. A file:// URL works."
|
|
245
|
+
},
|
|
246
|
+
"endpoint": {
|
|
247
|
+
"type": "string",
|
|
248
|
+
"description": "An existing CDP endpoint, for example http://127.0.0.1:9222. Given this, taste-check connects instead of launching, and never closes a browser it did not start."
|
|
249
|
+
},
|
|
250
|
+
"browserPath": {
|
|
251
|
+
"type": "string",
|
|
252
|
+
"description": "Path to a Chromium. Falls back to CHROME_PATH, then the usual locations for the platform."
|
|
253
|
+
},
|
|
254
|
+
"timeout": {
|
|
255
|
+
"type": "number",
|
|
256
|
+
"exclusiveMinimum": 0,
|
|
257
|
+
"description": "Milliseconds to wait for the browser and for each page operation. Default 15000."
|
|
258
|
+
},
|
|
259
|
+
"states": {
|
|
260
|
+
"type": "array",
|
|
261
|
+
"minItems": 1,
|
|
262
|
+
"description": "States to measure the page in. Omit for a single default state.",
|
|
263
|
+
"items": {
|
|
264
|
+
"type": "object",
|
|
265
|
+
"additionalProperties": false,
|
|
266
|
+
"required": [
|
|
267
|
+
"name"
|
|
268
|
+
],
|
|
269
|
+
"properties": {
|
|
270
|
+
"name": {
|
|
271
|
+
"type": "string",
|
|
272
|
+
"minLength": 1
|
|
273
|
+
},
|
|
274
|
+
"before": {
|
|
275
|
+
"type": "string",
|
|
276
|
+
"description": "JavaScript run on every navigation ahead of the page's own scripts. Where a theme goes, because the page reads it at boot."
|
|
277
|
+
},
|
|
278
|
+
"after": {
|
|
279
|
+
"type": "string",
|
|
280
|
+
"description": "JavaScript run once the page has loaded. For opening a panel or focusing a field."
|
|
281
|
+
},
|
|
282
|
+
"waitFor": {
|
|
283
|
+
"type": "string",
|
|
284
|
+
"description": "A selector to wait for before measuring."
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
"targets": {
|
|
290
|
+
"type": "array",
|
|
291
|
+
"minItems": 1,
|
|
292
|
+
"description": "What to measure. A selector matching nothing is a failure, as is a border colour on an edge with no width.",
|
|
293
|
+
"items": {
|
|
294
|
+
"type": "object",
|
|
295
|
+
"additionalProperties": false,
|
|
296
|
+
"required": [
|
|
297
|
+
"selector",
|
|
298
|
+
"prop",
|
|
299
|
+
"min"
|
|
300
|
+
],
|
|
301
|
+
"properties": {
|
|
302
|
+
"selector": {
|
|
303
|
+
"type": "string",
|
|
304
|
+
"minLength": 1
|
|
305
|
+
},
|
|
306
|
+
"prop": {
|
|
307
|
+
"type": "string",
|
|
308
|
+
"minLength": 1,
|
|
309
|
+
"description": "A computed style property carrying the colour, for example color or borderTopColor."
|
|
310
|
+
},
|
|
311
|
+
"min": {
|
|
312
|
+
"type": "number",
|
|
313
|
+
"exclusiveMinimum": 0
|
|
314
|
+
},
|
|
315
|
+
"label": {
|
|
316
|
+
"type": "string"
|
|
317
|
+
},
|
|
318
|
+
"againstParent": {
|
|
319
|
+
"type": "boolean",
|
|
320
|
+
"description": "Measure against what is behind the element rather than its own background. Use when the thing being measured is a fill: measuring a fill against itself scores 1.00 and means nothing."
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
227
326
|
}
|
|
228
327
|
}
|
|
229
328
|
}
|
package/src/cdp.mjs
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A very small Chrome DevTools Protocol client, over the WebSocket Node ships
|
|
3
|
+
* with. No dependencies.
|
|
4
|
+
*
|
|
5
|
+
* The alternative was a peer dependency on a browser automation library, which
|
|
6
|
+
* would have been less code here and several hundred megabytes there. CDP is
|
|
7
|
+
* JSON over a socket; the part of it this needs is Page.navigate and
|
|
8
|
+
* Runtime.evaluate, and that part is small enough to own.
|
|
9
|
+
*
|
|
10
|
+
* This is not a browser automation library and should not grow into one. If a
|
|
11
|
+
* feature here starts wanting selectors, waiting strategies or a frame tree,
|
|
12
|
+
* that is the point to take the dependency instead.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
/** Where a Chromium lives on each platform, in the order worth trying. */
|
|
20
|
+
const BROWSERS = {
|
|
21
|
+
darwin: [
|
|
22
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
23
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
24
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
25
|
+
],
|
|
26
|
+
linux: [
|
|
27
|
+
'/usr/bin/google-chrome',
|
|
28
|
+
'/usr/bin/google-chrome-stable',
|
|
29
|
+
'/usr/bin/chromium',
|
|
30
|
+
'/usr/bin/chromium-browser',
|
|
31
|
+
'/snap/bin/chromium',
|
|
32
|
+
],
|
|
33
|
+
win32: [
|
|
34
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
35
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function findBrowser(configured) {
|
|
40
|
+
const candidates = [configured, process.env.CHROME_PATH, ...(BROWSERS[process.platform] ?? [])];
|
|
41
|
+
for (const path of candidates) {
|
|
42
|
+
if (path && existsSync(path)) return path;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
48
|
+
|
|
49
|
+
/** Poll the endpoint until Chrome answers, or give up with a useful message. */
|
|
50
|
+
async function waitForEndpoint(base, timeout) {
|
|
51
|
+
const deadline = Date.now() + timeout;
|
|
52
|
+
let last;
|
|
53
|
+
while (Date.now() < deadline) {
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(`${base}/json/version`);
|
|
56
|
+
if (res.ok) return await res.json();
|
|
57
|
+
} catch (error) {
|
|
58
|
+
last = error;
|
|
59
|
+
}
|
|
60
|
+
await sleep(100);
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`no CDP endpoint at ${base} after ${timeout}ms${last ? `: ${last.message}` : ''}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A page to evaluate against, plus a close() that tears down whatever this
|
|
67
|
+
* function created and nothing it did not. Connecting to a browser somebody
|
|
68
|
+
* else started must not kill it.
|
|
69
|
+
*/
|
|
70
|
+
export async function connect({ endpoint, browserPath, timeout = 15000 }) {
|
|
71
|
+
let child = null;
|
|
72
|
+
let profile = null;
|
|
73
|
+
let base = endpoint;
|
|
74
|
+
|
|
75
|
+
if (!base) {
|
|
76
|
+
const binary = findBrowser(browserPath);
|
|
77
|
+
if (!binary) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
'no Chrome or Chromium found. Set runtime.browserPath, or CHROME_PATH, or ' +
|
|
80
|
+
'start a browser with --remote-debugging-port and set runtime.endpoint.',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
// Port 0 lets the OS choose, so two runs never collide. Chrome writes the
|
|
84
|
+
// port it actually took into the profile directory.
|
|
85
|
+
profile = mkdtempSync(join(tmpdir(), 'taste-check-cdp-'));
|
|
86
|
+
child = spawn(
|
|
87
|
+
binary,
|
|
88
|
+
[
|
|
89
|
+
'--headless=new',
|
|
90
|
+
'--remote-debugging-port=0',
|
|
91
|
+
`--user-data-dir=${profile}`,
|
|
92
|
+
'--no-first-run',
|
|
93
|
+
'--no-default-browser-check',
|
|
94
|
+
'--disable-extensions',
|
|
95
|
+
'--disable-background-networking',
|
|
96
|
+
'--hide-scrollbars',
|
|
97
|
+
'about:blank',
|
|
98
|
+
],
|
|
99
|
+
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
|
100
|
+
);
|
|
101
|
+
const port = await new Promise((resolve, reject) => {
|
|
102
|
+
let buffer = '';
|
|
103
|
+
const onData = (chunk) => {
|
|
104
|
+
buffer += chunk;
|
|
105
|
+
const match = buffer.match(/ws:\/\/127\.0\.0\.1:(\d+)\//);
|
|
106
|
+
if (match) settle(resolve, Number(match[1]));
|
|
107
|
+
};
|
|
108
|
+
const onExit = (code) => settle(reject, new Error(`the browser exited with code ${code}`));
|
|
109
|
+
const timer = setTimeout(
|
|
110
|
+
() => settle(reject, new Error('the browser did not report a debugging port')),
|
|
111
|
+
timeout,
|
|
112
|
+
);
|
|
113
|
+
const settle = (fn, value) => {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
child.stderr.off('data', onData);
|
|
116
|
+
child.off('exit', onExit);
|
|
117
|
+
fn(value);
|
|
118
|
+
};
|
|
119
|
+
child.stderr.on('data', onData);
|
|
120
|
+
child.on('exit', onExit);
|
|
121
|
+
});
|
|
122
|
+
// Nothing reads the browser's stderr after this, and a piped stream with
|
|
123
|
+
// a live child on the other end holds the event loop open. Without both
|
|
124
|
+
// of these the process sits idle until the browser happens to exit, which
|
|
125
|
+
// reads as a mysteriously slow run rather than as a leak.
|
|
126
|
+
child.stderr.destroy();
|
|
127
|
+
child.unref();
|
|
128
|
+
base = `http://127.0.0.1:${port}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await waitForEndpoint(base, timeout);
|
|
132
|
+
const list = await (await fetch(`${base}/json/list`)).json();
|
|
133
|
+
const target =
|
|
134
|
+
list.find((t) => t.type === 'page') ??
|
|
135
|
+
(await (await fetch(`${base}/json/new?about:blank`, { method: 'PUT' })).json());
|
|
136
|
+
|
|
137
|
+
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
138
|
+
await new Promise((resolve, reject) => {
|
|
139
|
+
ws.onopen = resolve;
|
|
140
|
+
ws.onerror = () => reject(new Error(`could not open a socket to ${target.webSocketDebuggerUrl}`));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
let id = 0;
|
|
144
|
+
const pending = new Map();
|
|
145
|
+
ws.onmessage = (event) => {
|
|
146
|
+
const message = JSON.parse(event.data);
|
|
147
|
+
if (!message.id || !pending.has(message.id)) return;
|
|
148
|
+
const { resolve, reject } = pending.get(message.id);
|
|
149
|
+
pending.delete(message.id);
|
|
150
|
+
if (message.error) reject(new Error(message.error.message));
|
|
151
|
+
else resolve(message.result);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Every timer here is cleared on the happy path. An uncancelled one keeps
|
|
155
|
+
// the event loop alive until it fires, which turns a fast run into a wait
|
|
156
|
+
// for the full timeout and looks exactly like a slow browser.
|
|
157
|
+
const send = (method, params = {}) =>
|
|
158
|
+
new Promise((resolve, reject) => {
|
|
159
|
+
const n = ++id;
|
|
160
|
+
const timer = setTimeout(() => {
|
|
161
|
+
if (pending.delete(n)) reject(new Error(`${method} timed out after ${timeout}ms`));
|
|
162
|
+
}, timeout);
|
|
163
|
+
pending.set(n, {
|
|
164
|
+
resolve: (value) => {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
resolve(value);
|
|
167
|
+
},
|
|
168
|
+
reject: (error) => {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
reject(error);
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
ws.send(JSON.stringify({ id: n, method, params }));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await send('Page.enable');
|
|
177
|
+
await send('Runtime.enable');
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
send,
|
|
181
|
+
/** Navigate and wait for the load event rather than for a fixed delay. */
|
|
182
|
+
async goto(url) {
|
|
183
|
+
let onMessage;
|
|
184
|
+
let timer;
|
|
185
|
+
const loaded = new Promise((resolve) => {
|
|
186
|
+
onMessage = (event) => {
|
|
187
|
+
if (JSON.parse(event.data).method === 'Page.loadEventFired') resolve();
|
|
188
|
+
};
|
|
189
|
+
ws.addEventListener('message', onMessage);
|
|
190
|
+
timer = setTimeout(resolve, timeout);
|
|
191
|
+
});
|
|
192
|
+
try {
|
|
193
|
+
await send('Page.navigate', { url });
|
|
194
|
+
await loaded;
|
|
195
|
+
} finally {
|
|
196
|
+
ws.removeEventListener('message', onMessage);
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
/**
|
|
201
|
+
* Register a script to run on every navigation, before the page's own
|
|
202
|
+
* scripts. This is where a theme goes: setting localStorage after load and
|
|
203
|
+
* reloading would wipe anything the reload undoes, and setting it without
|
|
204
|
+
* a reload is too late for a page that reads it on boot.
|
|
205
|
+
*/
|
|
206
|
+
async onNewDocument(source) {
|
|
207
|
+
const { identifier } = await send('Page.addScriptToEvaluateOnNewDocument', { source });
|
|
208
|
+
return identifier;
|
|
209
|
+
},
|
|
210
|
+
/** Remove one, so a state cannot leak into the next one. */
|
|
211
|
+
async removeNewDocumentScript(identifier) {
|
|
212
|
+
await send('Page.removeScriptToEvaluateOnNewDocument', { identifier });
|
|
213
|
+
},
|
|
214
|
+
/** Evaluate in the page and return the value, awaiting a promise result. */
|
|
215
|
+
async evaluate(expression) {
|
|
216
|
+
const { result, exceptionDetails } = await send('Runtime.evaluate', {
|
|
217
|
+
expression,
|
|
218
|
+
returnByValue: true,
|
|
219
|
+
awaitPromise: true,
|
|
220
|
+
});
|
|
221
|
+
if (exceptionDetails) {
|
|
222
|
+
throw new Error(exceptionDetails.exception?.description ?? exceptionDetails.text);
|
|
223
|
+
}
|
|
224
|
+
return result.value;
|
|
225
|
+
},
|
|
226
|
+
/**
|
|
227
|
+
* Best effort, and deliberately incapable of throwing. Teardown failing
|
|
228
|
+
* must never take down a run whose measurements already succeeded, and a
|
|
229
|
+
* temp directory the browser is still writing into is not worth a crash.
|
|
230
|
+
*/
|
|
231
|
+
close() {
|
|
232
|
+
try {
|
|
233
|
+
ws.close();
|
|
234
|
+
} catch {
|
|
235
|
+
/* already gone */
|
|
236
|
+
}
|
|
237
|
+
if (!child) return;
|
|
238
|
+
try {
|
|
239
|
+
child.kill();
|
|
240
|
+
} catch {
|
|
241
|
+
/* already gone */
|
|
242
|
+
}
|
|
243
|
+
if (profile) {
|
|
244
|
+
try {
|
|
245
|
+
rmSync(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
|
246
|
+
} catch {
|
|
247
|
+
/* the OS will collect it */
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
package/src/color.mjs
CHANGED
|
@@ -262,6 +262,22 @@ export function parseColor(input) {
|
|
|
262
262
|
return err(`"${text}" is not a colour this tool can parse`);
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Flatten a stack of backgrounds, nearest the element first, into one opaque
|
|
267
|
+
* colour.
|
|
268
|
+
*
|
|
269
|
+
* The check this ports from walked up to the first ancestor with alpha over
|
|
270
|
+
* 0.99 and measured against that, which quietly discards every translucent
|
|
271
|
+
* layer in between. A caption on a dark scrim over a light page is measured
|
|
272
|
+
* against the light page, and reports a pass it has not earned. Compositing
|
|
273
|
+
* the whole stack is what is actually painted.
|
|
274
|
+
*/
|
|
275
|
+
export function flatten(layers) {
|
|
276
|
+
let ground = layers[layers.length - 1].slice(0, 3);
|
|
277
|
+
for (let i = layers.length - 2; i >= 0; i -= 1) ground = composite(layers[i], ground);
|
|
278
|
+
return ground;
|
|
279
|
+
}
|
|
280
|
+
|
|
265
281
|
/** Composite a translucent foreground over an opaque ground. */
|
|
266
282
|
export function composite(fg, bg) {
|
|
267
283
|
return [0, 1, 2].map((i) => fg[3] * fg[i] + (1 - fg[3]) * bg[i]);
|
package/src/config.mjs
CHANGED
|
@@ -151,14 +151,88 @@ function validateJudge(judge, errors) {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
function validateRuntime(runtime, errors) {
|
|
155
|
+
rejectUnknown(
|
|
156
|
+
runtime,
|
|
157
|
+
['url', 'endpoint', 'browserPath', 'timeout', 'states', 'targets'],
|
|
158
|
+
'runtime',
|
|
159
|
+
errors,
|
|
160
|
+
);
|
|
161
|
+
if (typeof runtime.url !== 'string' || !runtime.url) {
|
|
162
|
+
errors.push('runtime.url must be the page to measure');
|
|
163
|
+
}
|
|
164
|
+
for (const key of ['endpoint', 'browserPath']) {
|
|
165
|
+
if (runtime[key] !== undefined && (typeof runtime[key] !== 'string' || !runtime[key])) {
|
|
166
|
+
errors.push(`runtime.${key} must be a non-empty string`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (runtime.timeout !== undefined && (typeof runtime.timeout !== 'number' || runtime.timeout <= 0)) {
|
|
170
|
+
errors.push('runtime.timeout must be a positive number of milliseconds');
|
|
171
|
+
}
|
|
172
|
+
if (runtime.states !== undefined) {
|
|
173
|
+
if (!Array.isArray(runtime.states) || !runtime.states.length) {
|
|
174
|
+
errors.push('runtime.states must be a non-empty array. Omit it for a single default state.');
|
|
175
|
+
} else {
|
|
176
|
+
const names = new Set();
|
|
177
|
+
runtime.states.forEach((state, i) => {
|
|
178
|
+
const where = `runtime.states[${i}]`;
|
|
179
|
+
if (!isPlainObject(state)) {
|
|
180
|
+
errors.push(`${where} must be an object`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
rejectUnknown(state, ['name', 'before', 'after', 'waitFor'], where, errors);
|
|
184
|
+
if (typeof state.name !== 'string' || !state.name) errors.push(`${where}.name must be a string`);
|
|
185
|
+
else if (names.has(state.name)) errors.push(`${where}.name "${state.name}" is used twice`);
|
|
186
|
+
else names.add(state.name);
|
|
187
|
+
for (const key of ['before', 'after', 'waitFor']) {
|
|
188
|
+
if (state[key] !== undefined && typeof state[key] !== 'string') {
|
|
189
|
+
errors.push(`${where}.${key} must be a string`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (!Array.isArray(runtime.targets) || !runtime.targets.length) {
|
|
196
|
+
errors.push('runtime.targets must be a non-empty array. A check with no targets cannot fail.');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
runtime.targets.forEach((target, i) => {
|
|
200
|
+
const where = `runtime.targets[${i}]`;
|
|
201
|
+
if (!isPlainObject(target)) {
|
|
202
|
+
errors.push(`${where} must be an object`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
rejectUnknown(target, ['selector', 'prop', 'min', 'label', 'againstParent'], where, errors);
|
|
206
|
+
for (const key of ['selector', 'prop']) {
|
|
207
|
+
if (typeof target[key] !== 'string' || !target[key]) {
|
|
208
|
+
errors.push(`${where}.${key} must be a non-empty string`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (typeof target.min !== 'number' || !Number.isFinite(target.min) || target.min <= 0) {
|
|
212
|
+
errors.push(`${where}.min must be a positive number`);
|
|
213
|
+
}
|
|
214
|
+
if (target.label !== undefined && typeof target.label !== 'string') {
|
|
215
|
+
errors.push(`${where}.label must be a string`);
|
|
216
|
+
}
|
|
217
|
+
if (target.againstParent !== undefined && typeof target.againstParent !== 'boolean') {
|
|
218
|
+
errors.push(`${where}.againstParent must be a boolean`);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
154
223
|
/** Validate a parsed config, returning a list of human-readable problems. */
|
|
155
224
|
export function validate(config) {
|
|
156
225
|
const errors = [];
|
|
157
226
|
if (!isPlainObject(config)) return ['the config must be a JSON object'];
|
|
158
|
-
rejectUnknown(config, ['$schema', 'contrast', 'treatments', 'judge'], 'the config', errors);
|
|
227
|
+
rejectUnknown(config, ['$schema', 'contrast', 'treatments', 'judge', 'runtime'], 'the config', errors);
|
|
159
228
|
|
|
160
|
-
if (
|
|
161
|
-
|
|
229
|
+
if (
|
|
230
|
+
config.contrast === undefined &&
|
|
231
|
+
config.treatments === undefined &&
|
|
232
|
+
config.judge === undefined &&
|
|
233
|
+
config.runtime === undefined
|
|
234
|
+
) {
|
|
235
|
+
errors.push('the config must define at least one of "contrast", "treatments", "runtime" or "judge"');
|
|
162
236
|
}
|
|
163
237
|
if (config.contrast !== undefined) {
|
|
164
238
|
if (isPlainObject(config.contrast)) validateContrast(config.contrast, errors);
|
|
@@ -172,6 +246,10 @@ export function validate(config) {
|
|
|
172
246
|
if (isPlainObject(config.judge)) validateJudge(config.judge, errors);
|
|
173
247
|
else errors.push('judge must be an object');
|
|
174
248
|
}
|
|
249
|
+
if (config.runtime !== undefined) {
|
|
250
|
+
if (isPlainObject(config.runtime)) validateRuntime(config.runtime, errors);
|
|
251
|
+
else errors.push('runtime must be an object');
|
|
252
|
+
}
|
|
175
253
|
return errors;
|
|
176
254
|
}
|
|
177
255
|
|
package/src/index.mjs
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
export { runContrast } from './contrast.mjs';
|
|
6
6
|
export { runTreatments } from './treatments.mjs';
|
|
7
7
|
export { runJudge, buildPrompt, checklistLines, extractJson } from './judge.mjs';
|
|
8
|
+
export { runRuntime } from './runtime.mjs';
|
|
9
|
+
export { connect, findBrowser } from './cdp.mjs';
|
|
8
10
|
export { load, validate } from './config.mjs';
|
|
9
11
|
export { toText, toJson, failed } from './report.mjs';
|
|
10
12
|
export { parseColor, contrastRatio, composite, luminance } from './color.mjs';
|
|
@@ -14,6 +16,7 @@ export { openTags, classesOf } from './treatments.mjs';
|
|
|
14
16
|
import { runContrast } from './contrast.mjs';
|
|
15
17
|
import { runTreatments } from './treatments.mjs';
|
|
16
18
|
import { runJudge } from './judge.mjs';
|
|
19
|
+
import { runRuntime } from './runtime.mjs';
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* Run the deterministic checks a config asks for. `only` narrows to one by
|
|
@@ -38,3 +41,12 @@ export function run(config, cwd, { only = null } = {}) {
|
|
|
38
41
|
export function judge(config, cwd) {
|
|
39
42
|
return [runJudge(config.judge, cwd)];
|
|
40
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Measure a rendered page. Separate from `run` because it needs a browser and
|
|
47
|
+
* a server that is already up, which is a heavier precondition than reading
|
|
48
|
+
* files off disk, and not one to impose on the check people put in a hook.
|
|
49
|
+
*/
|
|
50
|
+
export async function runtime(config, cwd) {
|
|
51
|
+
return [await runRuntime(config.runtime, cwd)];
|
|
52
|
+
}
|
package/src/report.mjs
CHANGED
|
@@ -52,7 +52,7 @@ function judgeLines(result) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export const linesFor = (result) => {
|
|
55
|
-
if (result.name === 'contrast') return contrastLines(result);
|
|
55
|
+
if (result.name === 'contrast' || result.name === 'runtime') return contrastLines(result);
|
|
56
56
|
if (result.name === 'judge') return judgeLines(result);
|
|
57
57
|
return treatmentLines(result);
|
|
58
58
|
};
|
|
@@ -79,7 +79,9 @@ export function toText(results) {
|
|
|
79
79
|
out.push(`${result.name} ok, ${result.summary}`);
|
|
80
80
|
// A clean contrast run still shows its margins. Nothing else in the
|
|
81
81
|
// report tells you which pair is one nudge away from failing.
|
|
82
|
-
if (result.name === 'contrast'
|
|
82
|
+
if (result.name === 'contrast' || result.name === 'runtime') {
|
|
83
|
+
for (const l of lines) out.push(` ${MARK.ok}${l.text}`);
|
|
84
|
+
}
|
|
83
85
|
out.push('');
|
|
84
86
|
continue;
|
|
85
87
|
}
|
package/src/runtime.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contrast measured off a rendered page.
|
|
3
|
+
*
|
|
4
|
+
* This is the check the contrast module is a static approximation of. A token
|
|
5
|
+
* file says what a colour is declared to be. A rendered page says what is
|
|
6
|
+
* actually painted, which is a different question the moment anything is
|
|
7
|
+
* translucent, inherited, set by a component, or overridden by a state.
|
|
8
|
+
*
|
|
9
|
+
* Two things it does that reading tokens cannot:
|
|
10
|
+
*
|
|
11
|
+
* 1. It composites the whole background stack, nearest layer first, rather
|
|
12
|
+
* than measuring against the first opaque ancestor. White text on a dark
|
|
13
|
+
* scrim over a light page reads 1.04:1 if you skip the scrim and 10.57:1
|
|
14
|
+
* if you do not. One of those is a false failure, and the same shortcut
|
|
15
|
+
* produces a false pass with the colours the other way round.
|
|
16
|
+
*
|
|
17
|
+
* 2. It can measure a state, because it can put the page into one first.
|
|
18
|
+
* A theme, an opened panel, a focused input.
|
|
19
|
+
*
|
|
20
|
+
* A selector that matches nothing is a failure, not a skipped target. So is a
|
|
21
|
+
* border with no width: you asked for the contrast of an edge that is not
|
|
22
|
+
* being drawn, and the honest answer is that the question is wrong.
|
|
23
|
+
*/
|
|
24
|
+
import { contrastRatio, flatten, isOpaque, parseColor } from './color.mjs';
|
|
25
|
+
import { connect } from './cdp.mjs';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Runs in the page. Returns the foreground and the stack of backgrounds behind
|
|
29
|
+
* it, and leaves every judgement to the caller so that the reasoning lives in
|
|
30
|
+
* one place rather than half here and half in a string.
|
|
31
|
+
*/
|
|
32
|
+
const MEASURE = `(targets) => targets.map(({ selector, prop, againstParent }) => {
|
|
33
|
+
const el = document.querySelector(selector);
|
|
34
|
+
if (!el) return { error: 'matched no element' };
|
|
35
|
+
if (!el.getClientRects().length) return { error: 'matched an element that is not rendered' };
|
|
36
|
+
const cs = getComputedStyle(el);
|
|
37
|
+
const fg = cs[prop];
|
|
38
|
+
if (!fg) return { error: 'has no ' + prop };
|
|
39
|
+
if (prop.startsWith('border')) {
|
|
40
|
+
const side = prop.replace(/^border|Color$/g, '');
|
|
41
|
+
const width = parseFloat(cs['border' + (side || 'Top') + 'Width']);
|
|
42
|
+
if (!width) return { error: 'has a ' + prop + ' but no width, so no edge is drawn' };
|
|
43
|
+
}
|
|
44
|
+
const layers = [];
|
|
45
|
+
let node = againstParent ? el.parentElement : el;
|
|
46
|
+
if (!node) return { error: 'has no parent to measure against' };
|
|
47
|
+
while (node) {
|
|
48
|
+
const bg = getComputedStyle(node).backgroundColor;
|
|
49
|
+
const m = bg && bg.match(/rgba?\\(([^)]+)\\)/);
|
|
50
|
+
if (m) {
|
|
51
|
+
const p = m[1].split(/[,\\/]/).map((v) => parseFloat(v.trim()));
|
|
52
|
+
const alpha = p.length > 3 ? p[3] : 1;
|
|
53
|
+
if (alpha > 0) {
|
|
54
|
+
layers.push(bg);
|
|
55
|
+
if (alpha > 0.999) break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
node = node.parentElement;
|
|
59
|
+
}
|
|
60
|
+
const root = getComputedStyle(document.documentElement).backgroundColor;
|
|
61
|
+
return { fg, layers, root, text: (el.textContent || '').trim().slice(0, 40) };
|
|
62
|
+
})`;
|
|
63
|
+
|
|
64
|
+
export async function runRuntime(config, cwd, { connect: open = connect } = {}) {
|
|
65
|
+
const samples = [];
|
|
66
|
+
const problems = [];
|
|
67
|
+
const { url, endpoint, browserPath, timeout, states = [{ name: 'default' }], targets } = config;
|
|
68
|
+
|
|
69
|
+
let page;
|
|
70
|
+
try {
|
|
71
|
+
page = await open({ endpoint, browserPath, timeout });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
problems.push(error.message);
|
|
74
|
+
return { name: 'runtime', samples, problems, summary: '' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
for (const state of states) {
|
|
79
|
+
// `before` runs on every navigation ahead of the page's own scripts, so
|
|
80
|
+
// a theme read at boot sees it. Running it after load and reloading
|
|
81
|
+
// would undo any DOM change; running it after load without reloading is
|
|
82
|
+
// too late for anything the page reads once. It is removed again after
|
|
83
|
+
// the state is measured, or the next state inherits it.
|
|
84
|
+
let initScript = null;
|
|
85
|
+
if (state.before) initScript = await page.onNewDocument(state.before);
|
|
86
|
+
|
|
87
|
+
await page.goto(url);
|
|
88
|
+
// `after` is the other half: opening a panel, focusing a field, anything
|
|
89
|
+
// that only exists once there is a document to act on.
|
|
90
|
+
if (state.after) await page.evaluate(state.after);
|
|
91
|
+
if (state.waitFor) {
|
|
92
|
+
await page.evaluate(
|
|
93
|
+
`new Promise((resolve, reject) => {
|
|
94
|
+
const done = () => document.querySelector(${JSON.stringify(state.waitFor)});
|
|
95
|
+
if (done()) return resolve(true);
|
|
96
|
+
const t = setInterval(() => { if (done()) { clearInterval(t); resolve(true); } }, 50);
|
|
97
|
+
setTimeout(() => { clearInterval(t); reject(new Error('waitFor ${state.waitFor} never appeared')); }, 10000);
|
|
98
|
+
})`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const measured = await page.evaluate(`(${MEASURE})(${JSON.stringify(targets)})`);
|
|
103
|
+
|
|
104
|
+
targets.forEach((target, i) => {
|
|
105
|
+
const found = measured[i];
|
|
106
|
+
const where = `${target.selector} { ${target.prop} }`;
|
|
107
|
+
if (found.error) {
|
|
108
|
+
problems.push(`state "${state.name}": ${where} ${found.error}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const fg = parseColor(found.fg);
|
|
112
|
+
if (!fg.ok) {
|
|
113
|
+
problems.push(`state "${state.name}": ${where} ${fg.reason}`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const layers = [];
|
|
117
|
+
for (const raw of found.layers) {
|
|
118
|
+
const parsed = parseColor(raw);
|
|
119
|
+
if (!parsed.ok) {
|
|
120
|
+
problems.push(`state "${state.name}": ${where} background ${parsed.reason}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
layers.push(parsed.rgba);
|
|
124
|
+
}
|
|
125
|
+
// Nothing opaque anywhere up the tree means the canvas is showing
|
|
126
|
+
// through, and the canvas is whatever the root paints.
|
|
127
|
+
if (!layers.length || !isOpaque(layers[layers.length - 1])) {
|
|
128
|
+
const root = parseColor(found.root);
|
|
129
|
+
layers.push(root.ok && isOpaque(root.rgba) ? root.rgba : [255, 255, 255, 1]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const ground = flatten(layers);
|
|
133
|
+
const ratio = contrastRatio(fg.rgba, ground);
|
|
134
|
+
samples.push({
|
|
135
|
+
theme: state.name,
|
|
136
|
+
fg: target.selector,
|
|
137
|
+
bg: `${target.prop}, ${layers.length} ${layers.length === 1 ? 'layer' : 'layers'}`,
|
|
138
|
+
note: target.label ?? found.text ?? '',
|
|
139
|
+
ratio,
|
|
140
|
+
min: target.min,
|
|
141
|
+
pass: ratio >= target.min,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (initScript) await page.removeNewDocumentScript(initScript);
|
|
146
|
+
}
|
|
147
|
+
} catch (error) {
|
|
148
|
+
problems.push(`the page could not be measured: ${error.message}`);
|
|
149
|
+
} finally {
|
|
150
|
+
page.close();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
name: 'runtime',
|
|
155
|
+
samples,
|
|
156
|
+
problems,
|
|
157
|
+
summary: `${samples.length} ${samples.length === 1 ? 'target' : 'targets'} across ${
|
|
158
|
+
states.length
|
|
159
|
+
} ${states.length === 1 ? 'state' : 'states'}`,
|
|
160
|
+
};
|
|
161
|
+
}
|