@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 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. These captures
133
- complement interaction checks for scrolling, state retention, keyboard navigation
134
- and touch readouts.
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.26",
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
  }