webview-preview 1.1.2 → 1.2.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/AGENTS.md CHANGED
@@ -66,6 +66,30 @@ element.getClientRects().length > 0
66
66
  If the project's config has macros (`macros` in `webview-preview.config.mjs` — signing in, opening a dialog), use them
67
67
  (`macro:signIn`): they are already correct. A flow you repeat more than twice belongs there too.
68
68
 
69
+ ## When the user wants to show you what to change
70
+
71
+ If the user says "I'll show you" or points at UI in words that do not pin an element down, let them point instead:
72
+
73
+ ```
74
+ npx webview-preview /screen <steps to reach it> annotate:review
75
+ ```
76
+
77
+ - It opens a **real window** and **waits for the person** — there is no run limit. Start it in the background (or
78
+ tell the user to run it) and wait for the process to end; do not poll the window.
79
+ - Read the stdout summary first: one line per note with the component, `file:line` and the note's text. The full
80
+ data is in `<prefix>annotate-review.json` next to the snapshots; pictures are `annotate-review.png` (with markers)
81
+ and `annotate-review-e1.png` … (each element). Open a picture only after the JSON.
82
+ - `component.source` with `confidence: "exact"` is where that element's JSX is written; `"nearest"` is where the
83
+ component around it is used (text inside a widget) — edit the call site, not the widget, unless the note says the
84
+ widget itself is wrong; `"file"` (Vue) has no line. Treat all of it as a strong hint and check the code.
85
+ - `selector` works in `measure:` / `snap:@` / `click:` for checking your change in the same state. If
86
+ `selectorQuality` is `"positional"`, it breaks once the DOM changes — measure before you edit.
87
+ - Notes can come from several pages (`note.url`), and from a frozen state (`note.isFrozen`): that state may not be
88
+ reachable with steps alone — say so if you cannot reproduce it.
89
+ - `status: "cancelled"` means the person closed it without notes; ask them, do not guess.
90
+ - Private elements come as `•••` with `isPrivate: true`; do not ask the user to remove the masking.
91
+ - Never put `annotate` into CI or a script that nobody watches; it never ends there.
92
+
69
93
  ## Errors in the page
70
94
 
71
95
  After the steps the tool prints the page's `console.error` calls, uncaught exceptions and unhandled rejections
@@ -244,4 +268,4 @@ host and DPI handling of a native window can differ.
244
268
  line. `assert:` shows only the final value; for `a === b` print `a` first if it fails.
245
269
  - Keyboard and hover behaviour checked with `key:` / `hover:` where the change touches them.
246
270
  - You looked at one snapshot of the final state.
247
- - You listed what could not be checked: fractional scroll, the other platform's engine, real backend data.
271
+ - You listed what could not be checked: fractional scroll, the other platform's engine, real backend data.
package/README.md CHANGED
@@ -28,6 +28,7 @@ plain init scripts and fit there too.
28
28
  - [Quick start](#quick-start)
29
29
  - [Config file](#config-file)
30
30
  - [Steps](#steps)
31
+ - [Annotate — show what to change](#annotate--show-what-to-change)
31
32
  - [Macros](#macros)
32
33
  - [Errors in the page](#errors-in-the-page)
33
34
  - [Command line](#command-line)
@@ -239,6 +240,7 @@ Steps run one after another, after the page has loaded and settled for 1.5 s.
239
240
  | `film:<name>:<ms>[/<n>][@<selector>]` | Takes n frames (10 by default, up to 60) evenly over ms, starting now, and puts them into one `<prefix><name>.png`, 5 per row, left to right. With a selector every frame is cropped to the area the element covered during the whole film (plus `--snap-margin`). Prints the real time of every frame: `film open: 10 frames at 0 55 111 … 500 ms`. Put it right after the step that starts the animation. |
240
241
  | `resize:<W>x<H>` | Resizes the window. |
241
242
  | `macro:<name>` | Runs the steps of a macro from the config. See [Macros](#macros). |
243
+ | `annotate` / `annotate:<name>` | Opens a real window with a panel where a person clicks elements and writes what to change, and waits for **Done**. Writes a JSON and a Markdown report with pictures. See [Annotate](#annotate--show-what-to-change). |
242
244
 
243
245
  ### Selectors
244
246
 
@@ -274,6 +276,107 @@ npx webview-preview / \
274
276
 
275
277
  On Windows PowerShell use the same double quotes; inside them, keep JavaScript strings in single quotes.
276
278
 
279
+ ## Annotate — show what to change
280
+
281
+ Describing a change in words ("the button on the right is a bit off") leaves whoever makes it guessing which element
282
+ you mean. `annotate` lets you point at it instead: the run opens a real window, you click elements and write a note
283
+ next to each, press **Done**, and get the exact element, where it comes from in the code and how it looks now.
284
+
285
+ ```sh
286
+ npx webview-preview /settings annotate
287
+ npx webview-preview /auth macro:signIn "click:text=Stats" annotate:stats snap:after
288
+ npx webview-preview https://example.com annotate # any site works
289
+ ```
290
+
291
+ ![Picking an element: the outline shows the element, its parent and its size; the tools are on the left](assets/readme/annotate-pick.png)
292
+
293
+ The steps before `annotate` reach the screen; the steps after it run once you are done. The step waits for you as
294
+ long as it takes (no run limit unless you pass `--timeout`) and always opens a window — WebKitGTK as with `--headed`,
295
+ the other engines without `headless`.
296
+
297
+ **The panel** sits in the top-left corner, as a strip of icons; drag it by the dots on top.
298
+
299
+ | Tool | Key | What it does |
300
+ |---|---|---|
301
+ | Show / hide tools | `T` | Folds the strip down to one icon. |
302
+ | Pick | `P` | Hover outlines an element with its size and its parent (`div.card › span "23" · 253 × 40`); a click picks it. Clicks do not reach the page while Pick is on — turn it off to use the app (open a menu, go to another screen), then on again. |
303
+ | Notes | `N` | The list of notes. It also opens when you pick an element, with the cursor in the note's text. |
304
+ | Freeze | `F` | Counts down 3 s (on the button) — hover what has to stay open meanwhile. Then animations stop and the page no longer hears the mouse leave or focus go (`mouseleave`, `pointerout`, `blur`, `focusout`, mouse moves), so tooltips and menus stay open to be picked. `F` again unfreezes. A menu that closes on a timer or by its own logic can still close. |
305
+ | Key bindings | — | Click an action, press the new key. Saved for the next runs in your user settings (`~/.config/webview-preview/annotate.json` on Linux, `%APPDATA%\webview-preview\` on Windows, `~/Library/Application Support/webview-preview/` on macOS). |
306
+ | Done / Cancel | `Ctrl+Enter` | Done writes the report; Cancel or closing the window ends with nothing. |
307
+
308
+ - **Click** — a new note; **Shift+click** — adds the element to the open note ("align these two"); clicking an
309
+ element that already has a note opens that note.
310
+ - **Alt + mouse wheel** or `↑` / `↓` — the parent / back to the child, when the click would land on an inner `<span>`.
311
+ - **Escape** closes the notes card; the notes stay. The **general note** at the bottom is about the whole screen.
312
+ - **Several pages:** go to another screen (turn Pick off, navigate, turn it on) — the notes stay, also when the page
313
+ reloads. Elements are cut out the moment you pick them; coming back to a page shows their markers again.
314
+ - Keys follow the physical key, so they work in any keyboard layout.
315
+
316
+ ![Two notes: the second one is about two buttons at once and is open in the notes card](assets/readme/annotate-notes.png)
317
+
318
+ ![Freeze: the tooltip that appears on hover stays open, so it can be picked](assets/readme/annotate-freeze.png)
319
+
320
+ **What you get.** A summary on stdout, one line per note:
321
+
322
+ ```
323
+ annotate stats: done · 2 note(s) · 2 element(s)
324
+ 1. StyledTextWidget span.text "1 284" (src/routes/stats/stats.parts.tsx:22) — make the number smaller
325
+ e1: [class*="stats__tiles"] > div:nth-child(1) > span:nth-child(2)
326
+ → .webview-preview/webkitgtk-annotate-stats.json
327
+ → .webview-preview/webkitgtk-annotate-stats.md
328
+ ```
329
+
330
+ and files in the snapshot folder:
331
+
332
+ | File | What |
333
+ |---|---|
334
+ | `<prefix>annotate-<name>.png` | The screen where you pressed Done, with the numbered markers. |
335
+ | `<prefix>annotate-<name>-<id>.png` | Every element cut out, without markers. |
336
+ | `<prefix>annotate-<name>.json` | Everything: notes, the page of each note, and for every element its selector, text, size, box, font and colours, and its component. |
337
+ | `<prefix>annotate-<name>.md` | A report for people: paste it into an issue as it is, pictures included. |
338
+
339
+ ![The marked screen from the report: numbered markers on the picked elements, the e-mail painted over](assets/readme/annotate-report.png)
340
+
341
+ These pictures are not snapshots of the run: `--compare` and `--update-baseline` leave them alone.
342
+
343
+ **Try it** on the demo app in this repository (a small React page made for these pictures):
344
+
345
+ ```sh
346
+ cd examples/demo && npm install
347
+ npx webview-preview / annotate
348
+ ```
349
+
350
+ **Where an element comes from in the code** — in development builds only:
351
+
352
+ - **React** (16–19): the component, the chain of components above it, and the file and line where its JSX is written,
353
+ mapped back through the dev server's source maps. `confidence: "exact"` is the element's own JSX;
354
+ `"nearest"` is where the component around it is used (for text inside a widget).
355
+ - **Vue** 2 and 3: the component, its chain and its `.vue` file (`confidence: "file"`, no line).
356
+ - Anything else, and production builds: no component, the rest of the report as usual.
357
+
358
+ Component names and lines come from React's and Vue's internals and are a strong hint, not a promise. The
359
+ `selector` is for this session: `selectorQuality: "positional"` (it has `:nth-child`) breaks when the page changes —
360
+ add a `data-testid` where you need a lasting one.
361
+
362
+ **Strict sites** (Content Security Policy, Trusted Types — YouTube, for example) work: the panel builds its DOM
363
+ without `innerHTML` and adopts its styles instead of inserting a `<style>`.
364
+
365
+ **Private data.** The report holds pictures and text from the page, and it may go to someone else or to an AI agent.
366
+ Some elements are hidden by default: password fields, card and one-time-code fields (`autocomplete`), and anything
367
+ inside `[data-private]` or `[data-webview-preview-private]`. Add your own selectors with `--private <selector>`
368
+ (repeatable) or in the config:
369
+
370
+ ```js
371
+ export default { annotate: { private: ["[class*=email]", ".token"] } }
372
+ ```
373
+
374
+ Hidden elements are painted over on screen and in every picture (line by line, so the text around them stays). Their
375
+ text becomes `•••`, also inside a picked parent, and their `aria-label` is left out of names and selectors. They get
376
+ `isPrivate: true`; the report counts them (`maskedCount`), and the panel says how many are hidden. It is a safety
377
+ net, not a guarantee — text drawn on a canvas or an e-mail in a plain `<span>` is not recognised — so prefer test
378
+ accounts and mock data when the report leaves your machine.
379
+
277
380
  ## Macros
278
381
 
279
382
  Steps you repeat — signing in, opening a dialog — go into the config under a name:
@@ -384,6 +487,7 @@ const result = await preview({
384
487
  result.engine; // "webkitgtk"
385
488
  result.saved; // ["/abs/path/.webview-preview/webkitgtk-dark.png"]
386
489
  result.failed; // false
490
+ result.annotations; // one report per annotate step (the same object as the .json file), or { status: "cancelled" }
387
491
  ```
388
492
 
389
493
  `preview()` reads the config file the same way the CLI does; pass `config: false` to skip it or `config: "path"` to
@@ -629,13 +733,13 @@ invisible rounding noise; raise it a little if antialiasing differs between mach
629
733
  Snapshots pile up in the snapshot folder. Clear them when you no longer need them:
630
734
 
631
735
  ```sh
632
- npx webview-preview clean # delete every *.png and report.html in the snapshot folder
736
+ npx webview-preview clean # delete every *.png, report.html and annotate report in the snapshot folder
633
737
  npx webview-preview clean --dry-run # only list what would be deleted
634
738
  npx webview-preview clean --older-than 2h # only files older than 2 hours (30m, 7d …)
635
739
  ```
636
740
 
637
741
  `clean` reads the same config (`out`, `baseline`) as a normal run. It deletes only `*.png` files directly in the
638
- snapshot folder (including `.diff.png`) and `report.html`, never the baselines, and refuses to run when the snapshot folder is the
742
+ snapshot folder (including `.diff.png`), `report.html` and the `annotate-*.json` / `.md` reports, never the baselines, and refuses to run when the snapshot folder is the
639
743
  project folder, your home folder, the disk root or the baseline folder.
640
744
 
641
745
  ## Recipes
@@ -755,6 +859,9 @@ The tests open `test/fixture/index.html` and check snapshots, input, focus order
755
859
  `film:`, compare and the report. GitHub Actions runs them on every push: WebKitGTK and Chromium on Linux, Edge and
756
860
  Chrome on Windows, WebKit on macOS; snapshots and reports of every run are kept as artifacts.
757
861
 
862
+ The pictures in this README come from `examples/demo`: `node examples/demo/readme-images.mjs` takes them again
863
+ (after `npm install` in `examples/demo`).
864
+
758
865
  ## License
759
866
 
760
- MIT © n0t4u404
867
+ MIT © n0t4u404. The icons of the annotate panel are from [Lucide](https://lucide.dev) (ISC).
@@ -24,7 +24,7 @@ const FLAGS = {
24
24
  "--color-scheme": "colorScheme",
25
25
  "--latency": "latency"
26
26
  }
27
- const LIST_FLAGS = { "--init": "init", "--init-file": "initFile", "--mock-file": "mockFile" }
27
+ const LIST_FLAGS = { "--init": "init", "--init-file": "initFile", "--mock-file": "mockFile", "--private": "private" }
28
28
 
29
29
  const HELP = `Usage: webview-preview [route-or-url] [steps...] [options]
30
30
  webview-preview clean [--dry-run] [--older-than 2h] [--out <dir>] [--config <file>]
@@ -79,6 +79,7 @@ Options:
79
79
  --base-url <url> prepended to routes that are not full URLs
80
80
  --init <code> run code before the page's own scripts, on every load (repeatable)
81
81
  --init-file <f> same, from a file (repeatable)
82
+ --private <css> annotate: hide these elements' text and pixels in the report too (repeatable)
82
83
  --mock-file <f> answer fetch/XHR requests from a .json/.mjs file of mocks (repeatable);
83
84
  every request is listed in window.__webviewPreviewRequests
84
85
  --log-requests only list fetch/XHR requests in window.__webviewPreviewRequests
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webview-preview",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Open your UI in the engine your desktop app really runs on (WebKitGTK, Edge/WebView2, WebKit), drive it with steps and save PNG snapshots.",
5
5
  "license": "MIT",
6
6
  "author": "n0t4u404",
@@ -0,0 +1,66 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const routeOf = (url, baseUrl) => (baseUrl && url.startsWith(baseUrl) ? url.slice(baseUrl.length) || "/" : url);
5
+
6
+ const localTime = date => {
7
+ const pad = value => String(value).padStart(2, "0");
8
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
9
+ }
10
+
11
+ const sourceText = source => (source ? `${source.file}${source.line ? `:${source.line}` : ""}` : null);
12
+
13
+ const markdown = report => {
14
+ const lines = [`# annotate: ${report.name}`, ""];
15
+ const { page } = report;
16
+ lines.push(`${page.route} · ${page.engine} · ${page.size.width}×${page.size.height} · ${localTime(new Date(page.takenAt))}`, "");
17
+ if (report.image) lines.push(`![${report.name}](${report.image})`, "");
18
+ for (const note of report.notes) {
19
+ lines.push(`## ${note.id}. ${note.text || "(no text)"}`, "");
20
+ if (note.url && note.url !== page.url) lines.push(`On another page: \`${routeOf(note.url, report.baseUrl)}\``, "");
21
+ for (const id of note.elements) {
22
+ const element = report.elements[id];
23
+ if (!element) continue;
24
+ const where = sourceText(element.component?.source);
25
+ const component = element.component?.name ? `\`${element.component.name}\` ` : "";
26
+ lines.push(`- ${component}${element.label}${where ? ` — \`${where}\`` : ""}`);
27
+ lines.push(` - selector: \`${element.selector}\``);
28
+ if (element.image) lines.push("", ` ![${id}](${element.image})`, "");
29
+ }
30
+ lines.push("");
31
+ }
32
+ if (report.generalNote) lines.push("## General note", "", report.generalNote, "");
33
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
34
+ }
35
+
36
+ export const writeAnnotationReport = ({ annotation, out, prefix, engine, url, baseUrl, size }) => {
37
+ const { result } = annotation;
38
+ const report = {
39
+ version: 1,
40
+ baseUrl,
41
+ name: annotation.name,
42
+ status: annotation.status,
43
+ page: {
44
+ url: result?.url ?? url,
45
+ route: routeOf(result?.url ?? url, baseUrl),
46
+ engine,
47
+ size: result?.viewport ?? size,
48
+ scale: annotation.scale ?? 1,
49
+ takenAt: new Date().toISOString()
50
+ },
51
+ image: annotation.image ?? null,
52
+ notes: result?.notes ?? [],
53
+ generalNote: result?.generalNote ?? null,
54
+ maskedCount: result?.maskedCount ?? 0,
55
+ elements: result?.elements ?? {}
56
+ }
57
+ if (result) {
58
+ delete result.url;
59
+ delete result.viewport;
60
+ }
61
+ const base = path.join(out, `${prefix}annotate-${annotation.name}`);
62
+ writeFileSync(`${base}.md`, markdown(report));
63
+ delete report.baseUrl;
64
+ writeFileSync(`${base}.json`, JSON.stringify(report, null, "\t"));
65
+ return { json: `${base}.json`, markdown: `${base}.md`, report }
66
+ }
@@ -0,0 +1,30 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ const FOLDER = "webview-preview";
6
+ const FILE = "annotate.json";
7
+
8
+ export const settingsFile = () => {
9
+ if (process.env.WEBVIEW_PREVIEW_SETTINGS) return process.env.WEBVIEW_PREVIEW_SETTINGS;
10
+ if (process.platform === "linux") return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), FOLDER, FILE);
11
+ if (process.platform === "win32") return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), FOLDER, FILE);
12
+ if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", FOLDER, FILE);
13
+ return path.join(os.homedir(), `.${FOLDER}`, FILE);
14
+ }
15
+
16
+ export const loadAnnotateKeys = () => {
17
+ try {
18
+ const { keys } = JSON.parse(readFileSync(settingsFile(), "utf8"));
19
+ return keys && typeof keys === "object" ? keys : null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ export const saveAnnotateKeys = keys => {
26
+ const file = settingsFile();
27
+ mkdirSync(path.dirname(file), { recursive: true });
28
+ writeFileSync(file, JSON.stringify({ keys }, null, "\t"));
29
+ return file;
30
+ }
@@ -0,0 +1,140 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
7
+ const BASE64_VALUES = new Map([...BASE64].map((character, index) => [character, index]));
8
+ const SOURCE_MAP_COMMENT = /\/\/[#@]\s*sourceMappingURL=(\S+)\s*$/m;
9
+
10
+ export const decodeVlq = segment => {
11
+ const values = [];
12
+ let value = 0;
13
+ let shift = 0;
14
+ for (const character of segment) {
15
+ const digit = BASE64_VALUES.get(character);
16
+ if (digit === undefined) throw new Error(`bad source map character "${character}"`);
17
+ value += (digit & 31) << shift;
18
+ if (digit & 32) {
19
+ shift += 5;
20
+ continue;
21
+ }
22
+ values.push(value & 1 ? -(value >>> 1) : value >>> 1);
23
+ value = 0;
24
+ shift = 0;
25
+ }
26
+ return values;
27
+ }
28
+
29
+ export const originalPosition = (map, line, column) => {
30
+ const lines = map.mappings.split(";");
31
+ if (line < 1 || line > lines.length) return null;
32
+ let sourceIndex = 0;
33
+ let sourceLine = 0;
34
+ let sourceColumn = 0;
35
+ let best = null;
36
+ for (let index = 0; index < line; index += 1) {
37
+ let generatedColumn = 0;
38
+ for (const segment of lines[index].split(",")) {
39
+ if (!segment) continue;
40
+ const [columnDelta, sourceDelta, lineDelta, sourceColumnDelta] = decodeVlq(segment);
41
+ generatedColumn += columnDelta;
42
+ if (sourceDelta === undefined) continue;
43
+ sourceIndex += sourceDelta;
44
+ sourceLine += lineDelta;
45
+ sourceColumn += sourceColumnDelta;
46
+ if (index === line - 1 && generatedColumn <= column - 1) {
47
+ best = { source: map.sources[sourceIndex], line: sourceLine + 1, column: sourceColumn + 1 }
48
+ }
49
+ }
50
+ }
51
+ return best;
52
+ }
53
+
54
+ const readText = async url => {
55
+ if (url.startsWith("file:")) return readFile(fileURLToPath(url), "utf8");
56
+ const response = await fetch(url);
57
+ if (!response.ok) throw new Error(`${response.status}`);
58
+ return response.text();
59
+ }
60
+
61
+ const loadMap = async moduleUrl => {
62
+ const code = await readText(moduleUrl);
63
+ const reference = SOURCE_MAP_COMMENT.exec(code)?.[1];
64
+ if (!reference) return null;
65
+ if (reference.startsWith("data:")) {
66
+ const comma = reference.indexOf(",");
67
+ const isBase64 = reference.slice(0, comma).endsWith(";base64");
68
+ const body = reference.slice(comma + 1);
69
+ return JSON.parse(isBase64 ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body));
70
+ }
71
+ return JSON.parse(await readText(new URL(reference, moduleUrl).href));
72
+ }
73
+
74
+ const projectPath = (file, baseDir) => {
75
+ if (!path.isAbsolute(file)) return file.replaceAll("\\", "/");
76
+ const relative = path.relative(baseDir, path.resolve(file));
77
+ const isInside = relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
78
+ return (isInside ? relative : file).replaceAll("\\", "/");
79
+ }
80
+
81
+ const SKIPPED_FOLDERS = new Set(["node_modules", ".git", "dist", "build", "target", "out"]);
82
+
83
+ const projectRoots = baseDir => {
84
+ const roots = [baseDir];
85
+ const walk = (folder, depth) => {
86
+ if (depth === 0) return;
87
+ let entries = [];
88
+ try {
89
+ entries = readdirSync(folder, { withFileTypes: true });
90
+ } catch {
91
+ return;
92
+ }
93
+ for (const entry of entries) {
94
+ if (!entry.isDirectory() || entry.name.startsWith(".") || SKIPPED_FOLDERS.has(entry.name)) continue;
95
+ const child = path.join(folder, entry.name);
96
+ roots.push(child);
97
+ walk(child, depth - 1);
98
+ }
99
+ }
100
+ walk(baseDir, 2);
101
+ return roots;
102
+ }
103
+
104
+ const onDisk = (file, roots) => {
105
+ if (existsSync(file)) return file;
106
+ return roots.map(root => path.join(root, file)).find(candidate => existsSync(candidate)) ?? file;
107
+ }
108
+
109
+ const sourcePath = (map, source, moduleUrl, baseDir, roots) => {
110
+ const root = map.sourceRoot ? new URL(map.sourceRoot, moduleUrl).href : moduleUrl;
111
+ let file = source;
112
+ if (/^[a-z][\w+.-]*:/i.test(source)) file = source.startsWith("file:") ? fileURLToPath(source) : source;
113
+ else if (!path.isAbsolute(source)) {
114
+ const resolved = new URL(source, root);
115
+ file = resolved.protocol === "file:" ? fileURLToPath(resolved) : decodeURIComponent(resolved.pathname);
116
+ }
117
+ if (path.isAbsolute(file)) file = onDisk(file, roots());
118
+ return projectPath(file, baseDir);
119
+ }
120
+
121
+ export const createSourceResolver = baseDir => {
122
+ const maps = new Map();
123
+ let roots = null;
124
+ const getRoots = () => roots ??= projectRoots(baseDir);
125
+ const mapFor = url => {
126
+ if (!maps.has(url)) maps.set(url, loadMap(url).catch(() => null));
127
+ return maps.get(url);
128
+ }
129
+ return async frame => {
130
+ if (!frame) return null;
131
+ if (frame.file) {
132
+ const file = path.isAbsolute(frame.file) ? onDisk(frame.file, getRoots()) : frame.file;
133
+ return { file: projectPath(file, baseDir), line: frame.line, column: frame.column, how: frame.how ?? "debug-source" }
134
+ }
135
+ const map = await mapFor(frame.url);
136
+ const position = map && originalPosition(map, frame.line, frame.column);
137
+ if (!position) return { file: frame.url, line: frame.line, column: frame.column, how: "debug-stack", isMapped: false }
138
+ return { file: sourcePath(map, position.source, frame.url, baseDir, getRoots), line: position.line, column: position.column, how: "debug-stack" }
139
+ }
140
+ }
@@ -16,7 +16,7 @@ export const runTimeoutMs = (steps, waitForTimeoutMs) => TIMEOUT_MS + steps.redu
16
16
  : STEP_BUDGET_MS
17
17
  ), 0);
18
18
 
19
- const STEP_KINDS = new Set(["js", "jsnow", "wait", "waitfor", "snap", "print", "measure", "assert", "focusorder", "resize", "key", "type", "hover", "click", "offline", "online", "latency", "film"]);
19
+ const STEP_KINDS = new Set(["js", "jsnow", "wait", "waitfor", "snap", "print", "measure", "assert", "focusorder", "resize", "key", "type", "hover", "click", "offline", "online", "latency", "film", "annotate"]);
20
20
  export const ASSERT_MARKER = "__webviewPreviewAssert:";
21
21
  export const FOCUS_MARKER = "__webviewPreviewFocus:";
22
22
  const FOCUS_ORDER_DEFAULT = 20;
@@ -235,6 +235,11 @@ export const parseSteps = steps => steps.flatMap(step => {
235
235
  return { kind, value, check: waitForExpression(selector, isGone) }
236
236
  }
237
237
  if (kind === "focusorder") return focusOrderSteps(value, step);
238
+ if (kind === "annotate") {
239
+ const name = value || "annotate";
240
+ if (!/^[\w.-]+$/.test(name)) throw new Error(`annotate needs a name of letters, digits, "-", "_" or ".", got "${step}"`);
241
+ return { kind, value: name }
242
+ }
238
243
  if (kind === "film") return filmStep(value, step);
239
244
  if (NETWORK_STEPS.has(kind)) return networkStep(kind, value, step);
240
245
  if (kind === "assert") {
@@ -19,14 +19,15 @@ const loadPlaywright = async () => {
19
19
  export const runPlaywright = async ({
20
20
  engine, url, steps, init = [], size, locale,
21
21
  colorScheme, isMotionReduced = false, out, prefix, scale,
22
- snapMargin = 8, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, log
22
+ snapMargin = 8, isHeaded = false, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, log
23
23
  }) => {
24
24
  const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : Infinity;
25
25
  const playwright = await loadPlaywright();
26
26
  const browserType = engine === "webkit" ? playwright.webkit : playwright.chromium;
27
- const browser = await browserType.launch({ channel: CHANNELS[engine], headless: true });
27
+ const browser = await browserType.launch({ channel: CHANNELS[engine], headless: !isHeaded });
28
28
  const saved = [];
29
29
  const films = [];
30
+ const annotations = [];
30
31
  let failed = false;
31
32
 
32
33
  try {
@@ -71,8 +72,68 @@ export const runPlaywright = async ({
71
72
  films.push({ name: step.value, file: path.join(out, `${prefix}${step.value}.png`), scale: scale ?? 1, viewport: page.viewportSize(), frames });
72
73
  }
73
74
 
75
+ const cutOut = async (rect, file) => {
76
+ try {
77
+ await page.screenshot({ path: file, clip: clipRect(rect, snapMargin, page.viewportSize()) });
78
+ return path.basename(file);
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ const annotate = async step => {
85
+ const base = `${prefix}annotate-${step.value}`;
86
+ const images = {}
87
+ let draft = null;
88
+ await page.evaluate(step.start);
89
+ for (;;) {
90
+ if (page.isClosed()) {
91
+ annotations.push({ name: step.value, status: "cancelled", result: null });
92
+ return;
93
+ }
94
+ if (Date.now() > deadline) {
95
+ annotations.push({ name: step.value, status: "cancelled", result: null });
96
+ throw new Error(`timed out after ${timeoutMs} ms`);
97
+ }
98
+ const text = await page.evaluate(step.check).catch(() => null);
99
+ const answer = text && text !== "missing" ? JSON.parse(text) : null;
100
+ if (text === "missing") {
101
+ await page.evaluate(`${draft ? `window.${step.restoreKey} = ${JSON.stringify(draft)};` : ""}${step.start}`).catch(() => undefined);
102
+ } else if (answer?.status === "open") {
103
+ if (answer.draft) draft = answer.draft;
104
+ if (answer.captures?.length) {
105
+ await page.evaluate(step.hide).catch(() => undefined);
106
+ await page.waitForTimeout(80);
107
+ for (const capture of answer.captures) {
108
+ const image = await cutOut(capture.rect, path.join(out, `${base}-${capture.id}.png`));
109
+ if (image) images[capture.id] = image;
110
+ }
111
+ await page.evaluate(step.show).catch(() => undefined);
112
+ }
113
+ } else if (answer) {
114
+ const annotation = { ...answer, name: step.value, scale: scale ?? 1 }
115
+ if (annotation.status === "done" && annotation.result) {
116
+ await page.screenshot({ path: path.join(out, `${base}.png`) });
117
+ annotation.image = `${base}.png`;
118
+ await page.evaluate(step.cleanup).catch(() => undefined);
119
+ await page.waitForTimeout(160);
120
+ for (const [id, element] of Object.entries(annotation.result.elements)) {
121
+ element.image = element.isLive
122
+ ? await cutOut(element.rect, path.join(out, `${base}-${id}.png`)) ?? images[id] ?? null
123
+ : images[id] ?? null;
124
+ }
125
+ await page.evaluate(step.dispose).catch(() => undefined);
126
+ }
127
+ annotations.push(annotation);
128
+ return;
129
+ }
130
+ await new Promise(resolve => setTimeout(resolve, step.pollMs));
131
+ }
132
+ }
133
+
74
134
  const runStep = async step => {
75
135
  const { kind, value } = step;
136
+ if (kind === "annotate") return annotate(step);
76
137
  if (kind === "snap") {
77
138
  const file = path.join(out, `${prefix}${value}.png`);
78
139
  const clip = step.selector ? clipRect(await elementRect(step), snapMargin, page.viewportSize()) : undefined;
@@ -133,5 +194,5 @@ export const runPlaywright = async ({
133
194
  await browser.close();
134
195
  }
135
196
 
136
- return { saved, failed, films }
197
+ return { saved, failed, films, annotations }
137
198
  }
@@ -48,6 +48,7 @@ export const runWebkitGtk = ({
48
48
 
49
49
  const saved = [];
50
50
  const films = [];
51
+ const annotations = [];
51
52
  let buffer = "";
52
53
  child.stdout.setEncoding("utf8");
53
54
  child.stdout.on("data", chunk => {
@@ -61,6 +62,7 @@ export const runWebkitGtk = ({
61
62
  const value = JSON.parse(line.slice(tab + 1));
62
63
  if (kind === "saved") saved.push(value);
63
64
  else if (kind === "film") films.push(value);
65
+ else if (kind === "annotate") annotations.push(value);
64
66
  else if (kind === "print") log.info(value);
65
67
  else if (kind === "error") log.error(value);
66
68
  else if (kind === "init-errors") reportInitErrors(value, log);
@@ -73,5 +75,5 @@ export const runWebkitGtk = ({
73
75
  }
74
76
  });
75
77
  child.once("error", reject);
76
- child.once("exit", code => resolve({ saved, failed: code !== 0, films }));
78
+ child.once("exit", code => resolve({ saved, failed: code !== 0, films, annotations }));
77
79
  });