@siyahi/tools 0.1.9-next.26 → 0.1.9-next.28
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 +28 -4
- package/contrast.mjs +3 -0
- package/package.json +1 -1
- package/shots.mjs +46 -7
package/README.md
CHANGED
|
@@ -97,7 +97,7 @@ counted (`moved=` against `in-view=`) and listed, but do not fail it. Run `npx p
|
|
|
97
97
|
## siyahi-shots
|
|
98
98
|
|
|
99
99
|
```sh
|
|
100
|
-
npx siyahi-shots http://localhost:5173/#/page [more URLs] [--out shots] [--gutter 16] [--font Geist] [--chrome '[data-slot="sidebar"]'] [--wait-for h1] [--report] [--widths 1920,390]
|
|
100
|
+
npx siyahi-shots http://localhost:5173/#/page [more URLs] [--out shots] [--gutter 16] [--font Geist] [--chrome '[data-slot="sidebar"]'] [--wait-for h1] [--report] [--widths 1920,390] [--script probe.mjs]
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
Renders each URL in Chromium at 1920 and 390 pixels wide, light and dark, and
|
|
@@ -129,9 +129,33 @@ for other pages. Run `npx playwright install chromium` once.
|
|
|
129
129
|
|
|
130
130
|
Pass `--widths 320,390,768,1366,1920` to include small phones, tablets and laptops;
|
|
131
131
|
each width runs in light and dark. The check also rejects a scrolling table whose
|
|
132
|
-
frozen columns occupy more than half its available width.
|
|
133
|
-
|
|
134
|
-
and
|
|
132
|
+
frozen columns occupy more than half its available width.
|
|
133
|
+
|
|
134
|
+
A page error or a console error, such as a failed request, fails the mode and is
|
|
135
|
+
printed under it.
|
|
136
|
+
|
|
137
|
+
To check a control, hover or tooltip, pass `--script` a module whose default
|
|
138
|
+
export takes Playwright's `page` and the mode (`{ name, width, height, dark }`).
|
|
139
|
+
It runs in every mode once the page is ready, at the mode's screen size; whatever
|
|
140
|
+
it returns is printed as `data=<JSON>` under the mode, and the checks measure the
|
|
141
|
+
page as the script left it. The full-page shot is taken after the viewport grows
|
|
142
|
+
to the page's height, which can undo a hover or close a tooltip, so take the
|
|
143
|
+
picture of an interaction inside the script, in the state it made. A locator's
|
|
144
|
+
`screenshot` gives just the card:
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
// hover.mjs: the tooltip of a chart's first bar, as text and as a picture
|
|
148
|
+
export default async (page, mode) => {
|
|
149
|
+
const card = page.locator('[data-slot="card"]', { hasText: "Revenue by channel" });
|
|
150
|
+
await card.locator(".recharts-bar-rectangle").first().hover();
|
|
151
|
+
await card.screenshot({ path: `shots/hover-${mode.name}.png` });
|
|
152
|
+
return (await card.locator(".recharts-tooltip-wrapper").innerText()).split("\n");
|
|
153
|
+
};
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
npx siyahi-shots http://localhost:4173/#/data/bar-chart --script hover.mjs
|
|
158
|
+
```
|
|
135
159
|
|
|
136
160
|
## siyahi-contrast
|
|
137
161
|
|
package/contrast.mjs
CHANGED
|
@@ -90,6 +90,9 @@ const PAIRS = [
|
|
|
90
90
|
...["", "muted", "muted/50"].map((bg) => ({ fg: "input", bg, min: MARK })),
|
|
91
91
|
{ fg: "input", bg: "input/30", min: MARK, scheme: "dark" },
|
|
92
92
|
...["ring", "destructive"].map((fg) => ({ fg, bg: "", min: MARK })),
|
|
93
|
+
// A toggle's focus ring also meets the button it rings: a selected one's
|
|
94
|
+
// foreground fill, or a hovered one's muted fill.
|
|
95
|
+
...["foreground", "muted"].map((bg) => ({ fg: "ring", bg, min: MARK })),
|
|
93
96
|
// A tone's icon or StatusBadge dot against the tone's own wash (lib/tone.ts).
|
|
94
97
|
...[
|
|
95
98
|
["link", "chart-1/8"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@siyahi/tools",
|
|
3
|
-
"version": "0.1.9-next.
|
|
3
|
+
"version": "0.1.9-next.28",
|
|
4
4
|
"description": "Checks and build helpers for Siyahi pages: a lint plugin, a font preload plugin, a theme contrast check, and screenshot and layout shift scripts.",
|
|
5
5
|
"homepage": "https://siyahi.jatinbansal.com",
|
|
6
6
|
"license": "MIT",
|
package/shots.mjs
CHANGED
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
//
|
|
8
8
|
// siyahi-shots <url>... [--out shots] [--gutter 16] [--font Geist]
|
|
9
9
|
// [--chrome '[data-slot="sidebar"]'] [--wait-for h1] [--report]
|
|
10
|
+
// [--widths 1920,390] [--script probe.mjs]
|
|
11
|
+
//
|
|
12
|
+
// --script names a module whose default export, async (page, mode) => data,
|
|
13
|
+
// runs in every mode once the page is ready, at the mode's screen size: it
|
|
14
|
+
// clicks, hovers or types with Playwright's page, and whatever it returns is
|
|
15
|
+
// printed as JSON. The checks then measure the page as the script left it. The
|
|
16
|
+
// full-page shot comes after the viewport grows to the page's height, which can
|
|
17
|
+
// undo a hover, so a script takes its own picture of an interaction, in the
|
|
18
|
+
// state it made: await card.screenshot({ path: `hover-${mode.name}.png` }).
|
|
19
|
+
// A page error or console error fails the mode, with or without a script.
|
|
10
20
|
//
|
|
11
21
|
// Measures only once --wait-for (by default the page's h1) is visible, two
|
|
12
22
|
// animation frames have passed and the fonts are ready: a page that renders
|
|
@@ -33,6 +43,7 @@
|
|
|
33
43
|
import { mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
34
44
|
import path from "node:path";
|
|
35
45
|
import process from "node:process";
|
|
46
|
+
import { pathToFileURL } from "node:url";
|
|
36
47
|
import { parseArgs } from "node:util";
|
|
37
48
|
import { chromium, errors } from "playwright";
|
|
38
49
|
|
|
@@ -54,11 +65,12 @@ const { values, positionals } = parseArgs({
|
|
|
54
65
|
"wait-for": { type: "string", default: "h1" },
|
|
55
66
|
report: { type: "boolean", default: false },
|
|
56
67
|
widths: { type: "string", default: "1920,390" },
|
|
68
|
+
script: { type: "string" },
|
|
57
69
|
},
|
|
58
70
|
});
|
|
59
71
|
if (positionals.length === 0) {
|
|
60
72
|
console.error(
|
|
61
|
-
"usage: siyahi-shots <url>... [--out shots] [--gutter 16] [--font Geist] [--chrome <selector>] [--wait-for <selector>] [--report] [--widths 1920,390]",
|
|
73
|
+
"usage: siyahi-shots <url>... [--out shots] [--gutter 16] [--font Geist] [--chrome <selector>] [--wait-for <selector>] [--report] [--widths 1920,390] [--script probe.mjs]",
|
|
62
74
|
);
|
|
63
75
|
process.exit(2);
|
|
64
76
|
}
|
|
@@ -78,6 +90,23 @@ const MODES = [...new Set(widths)].flatMap((width) =>
|
|
|
78
90
|
})),
|
|
79
91
|
);
|
|
80
92
|
const minGutter = Number(values.gutter);
|
|
93
|
+
/**
|
|
94
|
+
* Loads --script's default export, or exits 2 before any browser starts, as for any other bad argument.
|
|
95
|
+
* @param {string} file
|
|
96
|
+
* @returns {Promise<(page: import("playwright").Page, mode: Mode) => Promise<unknown>>}
|
|
97
|
+
*/
|
|
98
|
+
async function loadScript(file) {
|
|
99
|
+
const module = await import(pathToFileURL(path.resolve(file)).href).catch((/** @type {Error} */ error) => {
|
|
100
|
+
console.error(`--script ${file} did not load: ${error.message}`);
|
|
101
|
+
process.exit(2);
|
|
102
|
+
});
|
|
103
|
+
if (typeof module.default !== "function") {
|
|
104
|
+
console.error(`--script ${file} must export a default async function (page, mode)`);
|
|
105
|
+
process.exit(2);
|
|
106
|
+
}
|
|
107
|
+
return module.default;
|
|
108
|
+
}
|
|
109
|
+
const act = values.script ? await loadScript(values.script) : undefined;
|
|
81
110
|
const waitFor = values["wait-for"];
|
|
82
111
|
mkdirSync(outDir, { recursive: true });
|
|
83
112
|
|
|
@@ -199,7 +228,7 @@ function clearShots(base) {
|
|
|
199
228
|
* @param {import("playwright").Browser} browser
|
|
200
229
|
* @param {string} url
|
|
201
230
|
* @param {Mode} mode
|
|
202
|
-
* @returns {Promise<{ probe: Probe, ready: boolean, height: number, files: string[] }>}
|
|
231
|
+
* @returns {Promise<{ probe: Probe, ready: boolean, height: number, files: string[], data: unknown, browserErrors: string[] }>}
|
|
203
232
|
*/
|
|
204
233
|
async function shoot(browser, url, mode) {
|
|
205
234
|
const context = await browser.newContext({
|
|
@@ -207,6 +236,12 @@ async function shoot(browser, url, mode) {
|
|
|
207
236
|
colorScheme: mode.dark ? "dark" : "light",
|
|
208
237
|
});
|
|
209
238
|
const page = await context.newPage();
|
|
239
|
+
/** @type {string[]} */
|
|
240
|
+
const browserErrors = [];
|
|
241
|
+
page.on("pageerror", (error) => browserErrors.push(`page error: ${error.message}`));
|
|
242
|
+
page.on("console", (message) => {
|
|
243
|
+
if (message.type() === "error") browserErrors.push(`console error: ${message.text()} (${message.location().url})`);
|
|
244
|
+
});
|
|
210
245
|
await page.goto(url, { waitUntil: "networkidle" });
|
|
211
246
|
const ready = await page.waitForSelector(waitFor, { timeout: WAIT_FOR_TIMEOUT }).then(
|
|
212
247
|
() => true,
|
|
@@ -216,6 +251,8 @@ async function shoot(browser, url, mode) {
|
|
|
216
251
|
},
|
|
217
252
|
);
|
|
218
253
|
await page.evaluate(SETTLE);
|
|
254
|
+
const data = act && (await act(page, mode));
|
|
255
|
+
if (act) await page.evaluate(SETTLE);
|
|
219
256
|
/** @type {Probe} */
|
|
220
257
|
const probe = await page.evaluate(PROBE);
|
|
221
258
|
const height = Math.max(probe.docHeight, mode.height);
|
|
@@ -234,16 +271,17 @@ async function shoot(browser, url, mode) {
|
|
|
234
271
|
files.push(file);
|
|
235
272
|
}
|
|
236
273
|
await context.close();
|
|
237
|
-
return { probe, ready, height, files };
|
|
274
|
+
return { probe, ready, height, files, data, browserErrors };
|
|
238
275
|
}
|
|
239
276
|
|
|
240
277
|
/**
|
|
241
278
|
* @param {Probe} p
|
|
242
279
|
* @param {boolean} ready whether --wait-for appeared
|
|
280
|
+
* @param {string[]} browserErrors
|
|
243
281
|
* @returns {string[]}
|
|
244
282
|
*/
|
|
245
|
-
function problemsOf(p, ready) {
|
|
246
|
-
const problems = [];
|
|
283
|
+
function problemsOf(p, ready, browserErrors) {
|
|
284
|
+
const problems = [...browserErrors];
|
|
247
285
|
if (!ready) problems.push(`nothing matching ${waitFor} appeared within ${WAIT_FOR_TIMEOUT / 1000}s (--wait-for)`);
|
|
248
286
|
if (p.gutterLeft !== null && p.gutterLeft < minGutter)
|
|
249
287
|
problems.push(`left gutter is ${p.gutterLeft}px, under ${minGutter}px`);
|
|
@@ -264,8 +302,8 @@ const browser = await chromium.launch();
|
|
|
264
302
|
let failed = false;
|
|
265
303
|
for (const url of positionals) {
|
|
266
304
|
for (const mode of MODES) {
|
|
267
|
-
const { probe, ready, height, files } = await shoot(browser, url, mode);
|
|
268
|
-
const problems = problemsOf(probe, ready);
|
|
305
|
+
const { probe, ready, height, files, data, browserErrors } = await shoot(browser, url, mode);
|
|
306
|
+
const problems = problemsOf(probe, ready, browserErrors);
|
|
269
307
|
failed ||= problems.length > 0;
|
|
270
308
|
const font = values.report && !probe.fontLoaded ? `${probe.font} (report, system fallback allowed)` : probe.font;
|
|
271
309
|
console.log(
|
|
@@ -273,6 +311,7 @@ for (const url of positionals) {
|
|
|
273
311
|
`overflow=${probe.overflow}/${probe.overflowWithScrollbar} clipped=${probe.clipped.length} font=${font} h1=${JSON.stringify(probe.h1)} ` +
|
|
274
312
|
`height=${height} ${files.join(" ")}`,
|
|
275
313
|
);
|
|
314
|
+
if (data !== undefined) console.log(` data=${JSON.stringify(data)}`);
|
|
276
315
|
for (const problem of problems) console.log(` ${problem}`);
|
|
277
316
|
}
|
|
278
317
|
}
|