webview-preview 1.1.2 → 1.2.1

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,36 @@ 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
+ - Put the steps that reach the screen (sign-in macro, navigation) **before** `annotate`: after a full reload they run
80
+ again, so the person does not end up on a sign-in page while you fix things.
81
+ - With `--keep-open` the person sends notes in **rounds** and the process keeps running: each round prints
82
+ `annotate <name>: sent (round N) · …` and its file paths at once. Watch the process output for these lines (a
83
+ monitor / tail on its output), fix, and let the person look at the result in the same window (hot reload); the
84
+ last round says `done` and the process ends.
85
+ - Read the stdout summary first: one line per note with the component, `file:line` and the note's text. The full
86
+ data is in `<prefix>annotate-review.json` next to the snapshots; pictures are `annotate-review.png` (with markers)
87
+ and `annotate-review-e1.png` … (each element). Open a picture only after the JSON.
88
+ - `component.source` with `confidence: "exact"` is where that element's JSX is written; `"nearest"` is where the
89
+ component around it is used (text inside a widget) — edit the call site, not the widget, unless the note says the
90
+ widget itself is wrong; `"file"` (Vue) has no line. Treat all of it as a strong hint and check the code.
91
+ - `selector` works in `measure:` / `snap:@` / `click:` for checking your change in the same state. If
92
+ `selectorQuality` is `"positional"`, it breaks once the DOM changes — measure before you edit.
93
+ - Notes can come from several pages (`note.url`), and from a frozen state (`note.isFrozen`): that state may not be
94
+ reachable with steps alone — say so if you cannot reproduce it.
95
+ - `status: "cancelled"` means the person closed it without notes; ask them, do not guess.
96
+ - Private elements come as `•••` with `isPrivate: true`; do not ask the user to remove the masking.
97
+ - Never put `annotate` into CI or a script that nobody watches; it never ends there.
98
+
69
99
  ## Errors in the page
70
100
 
71
101
  After the steps the tool prints the page's `console.error` calls, uncaught exceptions and unhandled rejections
@@ -244,4 +274,4 @@ host and DPI handling of a native window can differ.
244
274
  line. `assert:` shows only the final value; for `a === b` print `a` first if it fails.
245
275
  - Keyboard and hover behaviour checked with `key:` / `hover:` where the change touches them.
246
276
  - 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.
277
+ - 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,117 @@ 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 and ends the step (the window closes when the run ends); Cancel or closing the window ends with nothing. |
307
+ | Send | `Ctrl+Enter` | With `--keep-open` only: writes the report for the notes so far right away, clears the panel and **keeps the window open** — keep checking the page while it reloads with your fixes. Done (the tick) then sends what is left and ends. |
308
+
309
+ - **Click** — a new note; **Shift+click** — adds the element to the open note ("align these two"); clicking an
310
+ element that already has a note opens that note.
311
+ - **Alt + mouse wheel** or `↑` / `↓` — the parent / back to the child, when the click would land on an inner `<span>`.
312
+ - **Escape** closes the notes card; the notes stay. The **general note** at the bottom is about the whole screen.
313
+ - **Several pages:** go to another screen (turn Pick off, navigate, turn it on) — the notes stay, also when the page
314
+ reloads. Elements are cut out the moment you pick them; coming back to a page shows their markers again.
315
+ - **Full reloads** (a dev server reloading the page after a change, which loses a session kept in memory): the
316
+ steps before `annotate` run again — sign in, open the screen — then the page goes back to the address you were on
317
+ and the panel comes back with your notes. Snapshots, `print:` and asserts are not repeated; a replayed step that
318
+ fails (you were still signed in, say) only prints `replay: …` and does not fail the run.
319
+ - Keys follow the physical key, so they work in any keyboard layout.
320
+
321
+ ![Two notes: the second one is about two buttons at once and is open in the notes card](assets/readme/annotate-notes.png)
322
+
323
+ ![Freeze: the tooltip that appears on hover stays open, so it can be picked](assets/readme/annotate-freeze.png)
324
+
325
+ **One round or many.** By default the window closes after Done. With `--keep-open` (or `annotate: { keepOpen: true }`
326
+ in the config) every Send is a round of its own: the summary is printed and the files are written at once, named
327
+ `annotate-<name>`, then `annotate-<name>-2`, `-3` …; the status says `sent`, the last one `done`. Good for working
328
+ with someone fixing things while you look — the dev server's hot reload shows the fixes in the same window.
329
+
330
+ **What you get.** A summary on stdout, one line per note:
331
+
332
+ ```
333
+ annotate stats: done · 2 note(s) · 2 element(s)
334
+ 1. StyledTextWidget span.text "1 284" (src/routes/stats/stats.parts.tsx:22) — make the number smaller
335
+ e1: [class*="stats__tiles"] > div:nth-child(1) > span:nth-child(2)
336
+ → .webview-preview/webkitgtk-annotate-stats.json
337
+ → .webview-preview/webkitgtk-annotate-stats.md
338
+ ```
339
+
340
+ and files in the snapshot folder:
341
+
342
+ | File | What |
343
+ |---|---|
344
+ | `<prefix>annotate-<name>.png` | The screen where you pressed Done, with the numbered markers. |
345
+ | `<prefix>annotate-<name>-<id>.png` | Every element cut out, without markers. |
346
+ | `<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. |
347
+ | `<prefix>annotate-<name>.md` | A report for people: paste it into an issue as it is, pictures included. |
348
+
349
+ ![The marked screen from the report: numbered markers on the picked elements, the e-mail painted over](assets/readme/annotate-report.png)
350
+
351
+ These pictures are not snapshots of the run: `--compare` and `--update-baseline` leave them alone.
352
+
353
+ **Try it** on the demo app in this repository (a small React page made for these pictures):
354
+
355
+ ```sh
356
+ cd examples/demo && npm install
357
+ npx webview-preview / annotate
358
+ ```
359
+
360
+ **Where an element comes from in the code** — in development builds only:
361
+
362
+ - **React** (16–19): the component, the chain of components above it, and the file and line where its JSX is written,
363
+ mapped back through the dev server's source maps. `confidence: "exact"` is the element's own JSX;
364
+ `"nearest"` is where the component around it is used (for text inside a widget).
365
+ - **Vue** 2 and 3: the component, its chain and its `.vue` file (`confidence: "file"`, no line).
366
+ - Anything else, and production builds: no component, the rest of the report as usual.
367
+
368
+ Component names and lines come from React's and Vue's internals and are a strong hint, not a promise. The
369
+ `selector` is for this session: `selectorQuality: "positional"` (it has `:nth-child`) breaks when the page changes —
370
+ add a `data-testid` where you need a lasting one.
371
+
372
+ **Strict sites** (Content Security Policy, Trusted Types — YouTube, for example) work: the panel builds its DOM
373
+ without `innerHTML` and adopts its styles instead of inserting a `<style>`.
374
+
375
+ **Private data.** The report holds pictures and text from the page, and it may go to someone else or to an AI agent.
376
+ Some elements are hidden by default: password fields, card and one-time-code fields (`autocomplete`), and anything
377
+ inside `[data-private]` or `[data-webview-preview-private]`. Add your own selectors with `--private <selector>`
378
+ (repeatable) or in the config:
379
+
380
+ ```js
381
+ export default { annotate: { private: ["[class*=email]", ".token"] } }
382
+ ```
383
+
384
+ Hidden elements are painted over on screen and in every picture (line by line, so the text around them stays). Their
385
+ text becomes `•••`, also inside a picked parent, and their `aria-label` is left out of names and selectors. They get
386
+ `isPrivate: true`; the report counts them (`maskedCount`), and the panel says how many are hidden. It is a safety
387
+ net, not a guarantee — text drawn on a canvas or an e-mail in a plain `<span>` is not recognised — so prefer test
388
+ accounts and mock data when the report leaves your machine.
389
+
277
390
  ## Macros
278
391
 
279
392
  Steps you repeat — signing in, opening a dialog — go into the config under a name:
@@ -384,6 +497,7 @@ const result = await preview({
384
497
  result.engine; // "webkitgtk"
385
498
  result.saved; // ["/abs/path/.webview-preview/webkitgtk-dark.png"]
386
499
  result.failed; // false
500
+ result.annotations; // one report per annotate step (the same object as the .json file), or { status: "cancelled" }
387
501
  ```
388
502
 
389
503
  `preview()` reads the config file the same way the CLI does; pass `config: false` to skip it or `config: "path"` to
@@ -629,13 +743,13 @@ invisible rounding noise; raise it a little if antialiasing differs between mach
629
743
  Snapshots pile up in the snapshot folder. Clear them when you no longer need them:
630
744
 
631
745
  ```sh
632
- npx webview-preview clean # delete every *.png and report.html in the snapshot folder
746
+ npx webview-preview clean # delete every *.png, report.html and annotate report in the snapshot folder
633
747
  npx webview-preview clean --dry-run # only list what would be deleted
634
748
  npx webview-preview clean --older-than 2h # only files older than 2 hours (30m, 7d …)
635
749
  ```
636
750
 
637
751
  `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
752
+ 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
753
  project folder, your home folder, the disk root or the baseline folder.
640
754
 
641
755
  ## Recipes
@@ -755,6 +869,9 @@ The tests open `test/fixture/index.html` and check snapshots, input, focus order
755
869
  `film:`, compare and the report. GitHub Actions runs them on every push: WebKitGTK and Chromium on Linux, Edge and
756
870
  Chrome on Windows, WebKit on macOS; snapshots and reports of every run are kept as artifacts.
757
871
 
872
+ The pictures in this README come from `examples/demo`: `node examples/demo/readme-images.mjs` takes them again
873
+ (after `npm install` in `examples/demo`).
874
+
758
875
  ## License
759
876
 
760
- MIT © n0t4u404
877
+ 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,8 @@ 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)
83
+ --keep-open annotate: a Send button reports the notes and keeps the window open for more
82
84
  --mock-file <f> answer fetch/XHR requests from a .json/.mjs file of mocks (repeatable);
83
85
  every request is listed in window.__webviewPreviewRequests
84
86
  --log-requests only list fetch/XHR requests in window.__webviewPreviewRequests
@@ -125,6 +127,10 @@ const parseArgs = argv => {
125
127
  options.headed = true;
126
128
  continue;
127
129
  }
130
+ if (arg === "--keep-open") {
131
+ options.keepOpen = true;
132
+ continue;
133
+ }
128
134
  if (arg === "--report") {
129
135
  options.report = true;
130
136
  continue;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "webview-preview",
3
- "version": "1.1.2",
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.",
3
+ "version": "1.2.1",
4
+ "description": "Check a Tauri or Wails UI in the engine it really runs on — WebKitGTK, Edge (WebView2), WebKit — from the command line: steps, snapshots, baselines, and an annotate window to point at elements and say what to change.",
5
5
  "license": "MIT",
6
6
  "author": "n0t4u404",
7
7
  "repository": {
@@ -14,9 +14,19 @@
14
14
  "webview",
15
15
  "webkitgtk",
16
16
  "webview2",
17
+ "webkit",
18
+ "playwright",
17
19
  "screenshot",
18
20
  "visual-testing",
19
- "playwright"
21
+ "visual-regression",
22
+ "ui-testing",
23
+ "annotation",
24
+ "design-review",
25
+ "developer-tools",
26
+ "cli",
27
+ "ai-agents",
28
+ "react",
29
+ "vue"
20
30
  ],
21
31
  "type": "module",
22
32
  "main": "./src/index.mjs",
@@ -54,5 +64,6 @@
54
64
  },
55
65
  "devDependencies": {
56
66
  "playwright-core": "^1.64.1"
57
- }
67
+ },
68
+ "homepage": "https://github.com/n0t4u404/webview-preview#readme"
58
69
  }
@@ -0,0 +1,67 @@
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
+ round: annotation.round ?? 1,
44
+ page: {
45
+ url: result?.url ?? url,
46
+ route: routeOf(result?.url ?? url, baseUrl),
47
+ engine,
48
+ size: result?.viewport ?? size,
49
+ scale: annotation.scale ?? 1,
50
+ takenAt: new Date().toISOString()
51
+ },
52
+ image: annotation.image ?? null,
53
+ notes: result?.notes ?? [],
54
+ generalNote: result?.generalNote ?? null,
55
+ maskedCount: result?.maskedCount ?? 0,
56
+ elements: result?.elements ?? {}
57
+ }
58
+ if (result) {
59
+ delete result.url;
60
+ delete result.viewport;
61
+ }
62
+ const base = path.join(out, `${prefix}annotate-${annotation.name}${annotation.round > 1 ? `-${annotation.round}` : ""}`);
63
+ writeFileSync(`${base}.md`, markdown(report));
64
+ delete report.baseUrl;
65
+ writeFileSync(`${base}.json`, JSON.stringify(report, null, "\t"));
66
+ return { json: `${base}.json`, markdown: `${base}.md`, report }
67
+ }
@@ -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
+ }
@@ -36,6 +36,16 @@ const stopProcessTree = child => {
36
36
  }
37
37
  }
38
38
 
39
+ const STOP_TIMEOUT_MS = 3000;
40
+
41
+ const waitForClose = async (port, host) => {
42
+ const deadline = Date.now() + STOP_TIMEOUT_MS;
43
+ while (Date.now() < deadline) {
44
+ if (!await isPortOpen(port, host)) return;
45
+ await new Promise(resolve => setTimeout(resolve, 100));
46
+ }
47
+ }
48
+
39
49
  export const startServer = async server => {
40
50
  if (!server) return () => {}
41
51
  const { command, port, host = "localhost", cwd = process.cwd(), readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS } = server;
@@ -51,12 +61,13 @@ export const startServer = async server => {
51
61
  const stop = () => {
52
62
  running.delete(stop);
53
63
  stopProcessTree(child);
64
+ return waitForClose(port, host);
54
65
  }
55
66
  running.add(stop);
56
67
  try {
57
68
  await waitForPort(port, host, readyTimeoutMs);
58
69
  } catch (error) {
59
- stop();
70
+ await stop();
60
71
  throw error;
61
72
  }
62
73
  return stop;
@@ -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") {