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 +25 -1
- package/README.md +110 -3
- package/bin/webview-preview.mjs +2 -1
- package/package.json +1 -1
- package/src/core/annotate-report.mjs +66 -0
- package/src/core/annotate-settings.mjs +30 -0
- package/src/core/source-map.mjs +140 -0
- package/src/core/steps.mjs +6 -1
- package/src/engines/playwright.mjs +64 -3
- package/src/engines/webkitgtk.mjs +3 -1
- package/src/engines/webkitgtk.py +92 -6
- package/src/index.d.ts +49 -0
- package/src/index.mjs +47 -6
- 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 +1 -1
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
|
+

|
|
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
|
+
|
|
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
|
|
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`)
|
|
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).
|
package/bin/webview-preview.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
|
@@ -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
|
@@ -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:
|
|
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
|
});
|