webview-preview 1.1.1 → 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 +25 -1
- package/README.md +113 -4
- package/bin/webview-preview.mjs +20 -7
- package/package.json +1 -1
- package/src/core/annotate-report.mjs +66 -0
- package/src/core/annotate-settings.mjs +30 -0
- package/src/core/server.mjs +12 -2
- package/src/core/source-map.mjs +140 -0
- package/src/core/steps.mjs +16 -1
- package/src/engines/playwright.mjs +70 -3
- package/src/engines/webkitgtk.mjs +3 -1
- package/src/engines/webkitgtk.py +115 -16
- package/src/index.d.ts +50 -0
- package/src/index.mjs +48 -4
- package/src/page/annotate/dom.mjs +44 -0
- package/src/page/annotate/format.mjs +21 -0
- package/src/page/annotate/icons.mjs +12 -0
- package/src/page/annotate/index.mjs +50 -0
- package/src/page/annotate/keys.mjs +33 -0
- package/src/page/annotate/markup.mjs +37 -0
- package/src/page/annotate/panel.mjs +743 -0
- package/src/page/annotate/react.mjs +55 -0
- package/src/page/annotate/selector.mjs +130 -0
- package/src/page/annotate/styles.mjs +79 -0
- package/src/page/annotate/vue.mjs +53 -0
- package/src/snapshots/clean.mjs +2 -2
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)
|
|
@@ -170,6 +171,7 @@ npx webview-preview /settings snap:settings
|
|
|
170
171
|
| `headed` | WebKitGTK: show a real window instead of rendering off-screen. | `false` |
|
|
171
172
|
| `snapMargin` | Room in CSS pixels around `snap:name@selector`, so focus rings and shadows fit. | `8` |
|
|
172
173
|
| `waitTimeout` | How long `waitfor:` waits, ms. | `10000` |
|
|
174
|
+
| `timeout` | Limit for the whole run, ms; `0` turns it off. The run stops with `timed out after … ms` and keeps the snapshots taken so far. | 30 s plus a budget per step: its own time for `wait:` / `film:`, `waitTimeout` for `waitfor:`, 2 s + 0.1 s per character for `type:`, 2 s for any other step |
|
|
173
175
|
| `keepStorage` | WebKitGTK: keep `localStorage`, cookies and cache between runs. By default every run starts clean, like Playwright engines always do. | `false` |
|
|
174
176
|
| `compare` / `updateBaseline` / `baseline` / `maxDiffPixels` / `compareTolerance` | See [Comparing with a baseline](#comparing-with-a-baseline). | — |
|
|
175
177
|
| `init` | Code that runs **before the page's own scripts**, on every load: a string, `{ file: "path" }`, or a list of them. See [below](#1-init--set-things-up-before-the-app-starts). | — |
|
|
@@ -238,6 +240,7 @@ Steps run one after another, after the page has loaded and settled for 1.5 s.
|
|
|
238
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. |
|
|
239
241
|
| `resize:<W>x<H>` | Resizes the window. |
|
|
240
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). |
|
|
241
244
|
|
|
242
245
|
### Selectors
|
|
243
246
|
|
|
@@ -273,6 +276,107 @@ npx webview-preview / \
|
|
|
273
276
|
|
|
274
277
|
On Windows PowerShell use the same double quotes; inside them, keep JavaScript strings in single quotes.
|
|
275
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
|
+

|
|
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
|
+

|
|
317
|
+
|
|
318
|
+

|
|
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
|
+

|
|
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
|
+
|
|
276
380
|
## Macros
|
|
277
381
|
|
|
278
382
|
Steps you repeat — signing in, opening a dialog — go into the config under a name:
|
|
@@ -337,6 +441,7 @@ webview-preview [route-or-url] [steps...] [options]
|
|
|
337
441
|
| `--headed` | WebKitGTK: show a real window |
|
|
338
442
|
| `--snap-margin <px>` | Room around `snap:name@selector` |
|
|
339
443
|
| `--wait-timeout <ms>` | How long `waitfor:` waits |
|
|
444
|
+
| `--timeout <ms>` | Limit for the whole run; `0` = none |
|
|
340
445
|
| `--keep-storage` | WebKitGTK: keep storage between runs |
|
|
341
446
|
| `--report` | Write `<out>/report.html` with the output and all snapshots (baseline and difference after `--compare`) |
|
|
342
447
|
| `--compare` / `--update-baseline` | Compare snapshots with baselines / replace the baselines |
|
|
@@ -382,6 +487,7 @@ const result = await preview({
|
|
|
382
487
|
result.engine; // "webkitgtk"
|
|
383
488
|
result.saved; // ["/abs/path/.webview-preview/webkitgtk-dark.png"]
|
|
384
489
|
result.failed; // false
|
|
490
|
+
result.annotations; // one report per annotate step (the same object as the .json file), or { status: "cancelled" }
|
|
385
491
|
```
|
|
386
492
|
|
|
387
493
|
`preview()` reads the config file the same way the CLI does; pass `config: false` to skip it or `config: "path"` to
|
|
@@ -627,13 +733,13 @@ invisible rounding noise; raise it a little if antialiasing differs between mach
|
|
|
627
733
|
Snapshots pile up in the snapshot folder. Clear them when you no longer need them:
|
|
628
734
|
|
|
629
735
|
```sh
|
|
630
|
-
npx webview-preview clean # delete every *.png in the snapshot folder
|
|
736
|
+
npx webview-preview clean # delete every *.png, report.html and annotate report in the snapshot folder
|
|
631
737
|
npx webview-preview clean --dry-run # only list what would be deleted
|
|
632
738
|
npx webview-preview clean --older-than 2h # only files older than 2 hours (30m, 7d …)
|
|
633
739
|
```
|
|
634
740
|
|
|
635
741
|
`clean` reads the same config (`out`, `baseline`) as a normal run. It deletes only `*.png` files directly in the
|
|
636
|
-
snapshot folder (including `.diff.png`), 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
|
|
637
743
|
project folder, your home folder, the disk root or the baseline folder.
|
|
638
744
|
|
|
639
745
|
## Recipes
|
|
@@ -725,7 +831,7 @@ More of these — animations frame by frame, measuring, getting past login — i
|
|
|
725
831
|
| `Server did not open port …` | The `server.command` failed or uses another port. Run it by hand to see why; raise `readyTimeoutMs` for slow starts. |
|
|
726
832
|
| `"/route" is not a full URL` | Set `baseUrl` in the config or pass `--base-url`. |
|
|
727
833
|
| Blank snapshot | The page needs more time or threw on start: add `wait:2000`, and `print:document.body.innerHTML.length`. |
|
|
728
|
-
| `timed out after
|
|
834
|
+
| `timed out after … ms` | The run took longer than its limit (30 s plus a budget per step). A step is stuck — check the last output before the message — or the steps really need more: raise `--timeout`, or `--timeout 0` for no limit. |
|
|
729
835
|
| The app starts in its default state every time | Runs start with empty storage on purpose. Set what you need with [`init`](#1-init--set-things-up-before-the-app-starts), or pass `--keep-storage` (WebKitGTK) to carry it over. |
|
|
730
836
|
| `waitfor:… timed out` | The element never became visible, or keeps moving (an endless animation). Check with `measure:`; raise `--wait-timeout`; use `wait:` for elements that never stop moving. |
|
|
731
837
|
| `--headed` Linux snapshot is twice the size | A real window follows your screen's scale (HiDPI): PNG pixels = CSS pixels × scale. Off-screen snapshots are 1×. |
|
|
@@ -753,6 +859,9 @@ The tests open `test/fixture/index.html` and check snapshots, input, focus order
|
|
|
753
859
|
`film:`, compare and the report. GitHub Actions runs them on every push: WebKitGTK and Chromium on Linux, Edge and
|
|
754
860
|
Chrome on Windows, WebKit on macOS; snapshots and reports of every run are kept as artifacts.
|
|
755
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
|
+
|
|
756
865
|
## License
|
|
757
866
|
|
|
758
|
-
MIT © n0t4u404
|
|
867
|
+
MIT © n0t4u404. The icons of the annotate panel are from [Lucide](https://lucide.dev) (ISC).
|
package/bin/webview-preview.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { stopServers } from "#src/core/server.mjs";
|
|
3
4
|
import { cleanSnapshots, ENGINES, preview, watchPreview } from "#src/index.mjs";
|
|
4
5
|
|
|
6
|
+
const PARENT_POLL_MS = 1000;
|
|
7
|
+
|
|
5
8
|
const FLAGS = {
|
|
6
9
|
"--engine": "engine",
|
|
7
10
|
"--size": "size",
|
|
@@ -16,11 +19,12 @@ const FLAGS = {
|
|
|
16
19
|
"--compare-tolerance": "compareTolerance",
|
|
17
20
|
"--older-than": "olderThan",
|
|
18
21
|
"--wait-timeout": "waitTimeout",
|
|
22
|
+
"--timeout": "timeout",
|
|
19
23
|
"--locale": "locale",
|
|
20
24
|
"--color-scheme": "colorScheme",
|
|
21
25
|
"--latency": "latency"
|
|
22
26
|
}
|
|
23
|
-
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" }
|
|
24
28
|
|
|
25
29
|
const HELP = `Usage: webview-preview [route-or-url] [steps...] [options]
|
|
26
30
|
webview-preview clean [--dry-run] [--older-than 2h] [--out <dir>] [--config <file>]
|
|
@@ -63,6 +67,7 @@ Options:
|
|
|
63
67
|
--snap-margin <px> room around snap:name@selector (default: 8)
|
|
64
68
|
--headed WebKitGTK: show a real window instead of rendering off-screen
|
|
65
69
|
--wait-timeout <ms> how long waitfor waits (default: 10000)
|
|
70
|
+
--timeout <ms> limit for the whole run; 0 = none (default: 30 s plus a budget per step)
|
|
66
71
|
--keep-storage WebKitGTK: keep localStorage, cookies and cache between runs (default: start clean)
|
|
67
72
|
--report write <out>/report.html: the output and every snapshot, with baseline and
|
|
68
73
|
difference next to it after --compare
|
|
@@ -74,6 +79,7 @@ Options:
|
|
|
74
79
|
--base-url <url> prepended to routes that are not full URLs
|
|
75
80
|
--init <code> run code before the page's own scripts, on every load (repeatable)
|
|
76
81
|
--init-file <f> same, from a file (repeatable)
|
|
82
|
+
--private <css> annotate: hide these elements' text and pixels in the report too (repeatable)
|
|
77
83
|
--mock-file <f> answer fetch/XHR requests from a .json/.mjs file of mocks (repeatable);
|
|
78
84
|
every request is listed in window.__webviewPreviewRequests
|
|
79
85
|
--log-requests only list fetch/XHR requests in window.__webviewPreviewRequests
|
|
@@ -187,18 +193,25 @@ try {
|
|
|
187
193
|
if (steps.length) throw new Error(`clean takes no steps, got ${steps.join(" ")}`);
|
|
188
194
|
const { removed, out } = await cleanSnapshots(cleanOptions);
|
|
189
195
|
for (const file of removed) console.log(file);
|
|
190
|
-
console.log(`${cleanOptions.dryRun ? "would delete" : "deleted"} ${removed.length}
|
|
196
|
+
console.log(`${cleanOptions.dryRun ? "would delete" : "deleted"} ${removed.length} file(s) in ${out}`);
|
|
191
197
|
process.exit(0);
|
|
192
198
|
}
|
|
193
199
|
const { isWatching, ...previewOptions } = parsed.options;
|
|
200
|
+
let stopWatching = () => {}
|
|
201
|
+
const exitCleanly = code => {
|
|
202
|
+
stopWatching();
|
|
203
|
+
stopServers();
|
|
204
|
+
process.exit(code);
|
|
205
|
+
}
|
|
206
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.once(signal, () => exitCleanly(isWatching ? 0 : 1));
|
|
207
|
+
const parentPid = process.ppid;
|
|
208
|
+
setInterval(() => {
|
|
209
|
+
if (process.ppid !== parentPid) exitCleanly(1);
|
|
210
|
+
}, PARENT_POLL_MS).unref();
|
|
194
211
|
if (isWatching) {
|
|
195
|
-
|
|
212
|
+
stopWatching = await watchPreview(previewOptions, result => {
|
|
196
213
|
for (const file of result.saved) console.log(file);
|
|
197
214
|
});
|
|
198
|
-
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.once(signal, () => {
|
|
199
|
-
stop();
|
|
200
|
-
process.exit(0);
|
|
201
|
-
});
|
|
202
215
|
await new Promise(() => {});
|
|
203
216
|
}
|
|
204
217
|
const result = await preview(previewOptions);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webview-preview",
|
|
3
|
-
"version": "1.
|
|
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(``, "");
|
|
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("", ` `, "");
|
|
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
|
+
}
|
package/src/core/server.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import net from "node:net";
|
|
|
3
3
|
|
|
4
4
|
const POLL_MS = 250;
|
|
5
5
|
const DEFAULT_READY_TIMEOUT_MS = 30000;
|
|
6
|
+
const running = new Set();
|
|
6
7
|
|
|
7
8
|
const isPortOpen = (port, host) => new Promise(resolve => {
|
|
8
9
|
const socket = net.connect(port, host);
|
|
@@ -47,11 +48,20 @@ export const startServer = async server => {
|
|
|
47
48
|
stdio: "ignore",
|
|
48
49
|
detached: process.platform !== "win32"
|
|
49
50
|
});
|
|
51
|
+
const stop = () => {
|
|
52
|
+
running.delete(stop);
|
|
53
|
+
stopProcessTree(child);
|
|
54
|
+
}
|
|
55
|
+
running.add(stop);
|
|
50
56
|
try {
|
|
51
57
|
await waitForPort(port, host, readyTimeoutMs);
|
|
52
58
|
} catch (error) {
|
|
53
|
-
|
|
59
|
+
stop();
|
|
54
60
|
throw error;
|
|
55
61
|
}
|
|
56
|
-
return
|
|
62
|
+
return stop;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const stopServers = () => {
|
|
66
|
+
for (const stop of [...running]) stop();
|
|
57
67
|
}
|
|
@@ -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
|
+
}
|
package/src/core/steps.mjs
CHANGED
|
@@ -5,8 +5,18 @@ export const JS_SETTLE_MS = 700;
|
|
|
5
5
|
export const TIMEOUT_MS = 30000;
|
|
6
6
|
export const WAIT_FOR_TIMEOUT_MS = 10000;
|
|
7
7
|
export const WAIT_FOR_POLL_MS = 50;
|
|
8
|
+
const STEP_BUDGET_MS = 2000;
|
|
9
|
+
const TYPE_KEY_BUDGET_MS = 100;
|
|
8
10
|
|
|
9
|
-
const
|
|
11
|
+
export const runTimeoutMs = (steps, waitForTimeoutMs) => TIMEOUT_MS + steps.reduce((total, step) => total + (
|
|
12
|
+
step.kind === "wait" ? Number(step.value)
|
|
13
|
+
: step.kind === "film" ? step.durationMs + STEP_BUDGET_MS
|
|
14
|
+
: step.kind === "waitfor" ? waitForTimeoutMs
|
|
15
|
+
: step.kind === "type" ? STEP_BUDGET_MS + TYPE_KEY_BUDGET_MS * [...step.value].length
|
|
16
|
+
: STEP_BUDGET_MS
|
|
17
|
+
), 0);
|
|
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", "annotate"]);
|
|
10
20
|
export const ASSERT_MARKER = "__webviewPreviewAssert:";
|
|
11
21
|
export const FOCUS_MARKER = "__webviewPreviewFocus:";
|
|
12
22
|
const FOCUS_ORDER_DEFAULT = 20;
|
|
@@ -225,6 +235,11 @@ export const parseSteps = steps => steps.flatMap(step => {
|
|
|
225
235
|
return { kind, value, check: waitForExpression(selector, isGone) }
|
|
226
236
|
}
|
|
227
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
|
+
}
|
|
228
243
|
if (kind === "film") return filmStep(value, step);
|
|
229
244
|
if (NETWORK_STEPS.has(kind)) return networkStep(kind, value, step);
|
|
230
245
|
if (kind === "assert") {
|