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
|
@@ -19,13 +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, log
|
|
22
|
+
snapMargin = 8, isHeaded = false, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, log
|
|
23
23
|
}) => {
|
|
24
|
+
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : Infinity;
|
|
24
25
|
const playwright = await loadPlaywright();
|
|
25
26
|
const browserType = engine === "webkit" ? playwright.webkit : playwright.chromium;
|
|
26
|
-
const browser = await browserType.launch({ channel: CHANNELS[engine], headless:
|
|
27
|
+
const browser = await browserType.launch({ channel: CHANNELS[engine], headless: !isHeaded });
|
|
27
28
|
const saved = [];
|
|
28
29
|
const films = [];
|
|
30
|
+
const annotations = [];
|
|
29
31
|
let failed = false;
|
|
30
32
|
|
|
31
33
|
try {
|
|
@@ -70,8 +72,68 @@ export const runPlaywright = async ({
|
|
|
70
72
|
films.push({ name: step.value, file: path.join(out, `${prefix}${step.value}.png`), scale: scale ?? 1, viewport: page.viewportSize(), frames });
|
|
71
73
|
}
|
|
72
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
|
+
|
|
73
134
|
const runStep = async step => {
|
|
74
135
|
const { kind, value } = step;
|
|
136
|
+
if (kind === "annotate") return annotate(step);
|
|
75
137
|
if (kind === "snap") {
|
|
76
138
|
const file = path.join(out, `${prefix}${value}.png`);
|
|
77
139
|
const clip = step.selector ? clipRect(await elementRect(step), snapMargin, page.viewportSize()) : undefined;
|
|
@@ -113,6 +175,11 @@ export const runPlaywright = async ({
|
|
|
113
175
|
}
|
|
114
176
|
|
|
115
177
|
for (const step of steps) {
|
|
178
|
+
if (Date.now() > deadline) {
|
|
179
|
+
failed = true;
|
|
180
|
+
log.error(`timed out after ${timeoutMs} ms`);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
116
183
|
try {
|
|
117
184
|
await runStep(step);
|
|
118
185
|
} catch (error) {
|
|
@@ -127,5 +194,5 @@ export const runPlaywright = async ({
|
|
|
127
194
|
await browser.close();
|
|
128
195
|
}
|
|
129
196
|
|
|
130
|
-
return { saved, failed, films }
|
|
197
|
+
return { saved, failed, films, annotations }
|
|
131
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
|
});
|
package/src/engines/webkitgtk.py
CHANGED
|
@@ -17,6 +17,7 @@ INPUT_SETTLE_MS = 50
|
|
|
17
17
|
UNSUPPORTED_RESULT = "Unsupported result type"
|
|
18
18
|
KEY_POLL_MS = 20
|
|
19
19
|
KEY_WAIT_MS = 2000
|
|
20
|
+
ANNOTATE_SETTLE_MS = 80
|
|
20
21
|
KEYUPS = "window.__webviewPreviewKeyups || 0"
|
|
21
22
|
KEYUP_COUNTER = "addEventListener('keyup', () => { window.__webviewPreviewKeyups = (window.__webviewPreviewKeyups || 0) + 1 }, true);"
|
|
22
23
|
GDK_KEY_NAMES = {
|
|
@@ -149,7 +150,10 @@ def main():
|
|
|
149
150
|
if found and entries:
|
|
150
151
|
event.key.hardware_keycode = entries[0].keycode
|
|
151
152
|
event.set_device(seat.get_keyboard())
|
|
152
|
-
|
|
153
|
+
if kind == Gdk.EventType.KEY_RELEASE:
|
|
154
|
+
Gtk.main_do_event(event)
|
|
155
|
+
else:
|
|
156
|
+
deliver(event)
|
|
153
157
|
|
|
154
158
|
def press(keyval, modifiers=()):
|
|
155
159
|
mask = Gdk.ModifierType(0)
|
|
@@ -162,22 +166,31 @@ def main():
|
|
|
162
166
|
key_event(Gdk.EventType.KEY_RELEASE, Gdk.keyval_from_name(MODIFIER_KEYS[modifier]), mask)
|
|
163
167
|
mask &= ~MODIFIER_MASKS[modifier]
|
|
164
168
|
|
|
165
|
-
def press_all(presses):
|
|
169
|
+
def press_all(label, presses):
|
|
166
170
|
def count(text):
|
|
167
171
|
return int(text) if text and text.isdigit() else 0
|
|
168
172
|
|
|
169
173
|
def send(text):
|
|
170
|
-
|
|
174
|
+
start = count(text)
|
|
175
|
+
expected = start + sum(1 + len(modifiers) for _, modifiers in presses)
|
|
171
176
|
for keyval, modifiers in presses:
|
|
172
177
|
press(keyval, modifiers)
|
|
173
|
-
|
|
178
|
+
progress = {"keyups": start, "since": GLib.get_monotonic_time()}
|
|
174
179
|
|
|
175
180
|
def check():
|
|
176
181
|
def on_value(text):
|
|
177
|
-
|
|
182
|
+
keyups = count(text)
|
|
183
|
+
if keyups >= expected:
|
|
178
184
|
GLib.timeout_add(INPUT_SETTLE_MS, run_step)
|
|
179
|
-
|
|
180
|
-
|
|
185
|
+
return
|
|
186
|
+
now = GLib.get_monotonic_time()
|
|
187
|
+
if keyups > progress["keyups"]:
|
|
188
|
+
progress.update(keyups=keyups, since=now)
|
|
189
|
+
elif (now - progress["since"]) / 1000 >= KEY_WAIT_MS:
|
|
190
|
+
step_failed(f"{label}: only {keyups - start} of {expected - start} key presses reached the page")
|
|
191
|
+
GLib.timeout_add(INPUT_SETTLE_MS, run_step)
|
|
192
|
+
return
|
|
193
|
+
GLib.timeout_add(KEY_POLL_MS, lambda: (check(), False)[1])
|
|
181
194
|
|
|
182
195
|
evaluate(KEYUPS, on_value, lambda _: GLib.timeout_add(INPUT_SETTLE_MS, run_step))
|
|
183
196
|
|
|
@@ -205,10 +218,11 @@ def main():
|
|
|
205
218
|
)
|
|
206
219
|
return window.get_pixbuf()
|
|
207
220
|
|
|
208
|
-
def save(name, pixbuf):
|
|
221
|
+
def save(name, pixbuf, is_snapshot=True):
|
|
209
222
|
path = os.path.join(args.out, f"{args.prefix}{name}.png")
|
|
210
223
|
pixbuf.savev(path, "png", [], [])
|
|
211
|
-
|
|
224
|
+
if is_snapshot:
|
|
225
|
+
emit("saved", path)
|
|
212
226
|
|
|
213
227
|
def evaluate(code, on_value, on_error):
|
|
214
228
|
def on_result(webview, result):
|
|
@@ -234,7 +248,7 @@ def main():
|
|
|
234
248
|
|
|
235
249
|
evaluate(step["rect"], on_value, lambda error: (step_failed(f"{step['kind']}:{step['value']} failed: {error}"), next_step()))
|
|
236
250
|
|
|
237
|
-
def
|
|
251
|
+
def crop(name, rect, is_snapshot=True):
|
|
238
252
|
pixbuf = full_pixbuf()
|
|
239
253
|
width, height = view.get_allocated_width(), view.get_allocated_height()
|
|
240
254
|
scale = pixbuf.get_width() / width
|
|
@@ -244,12 +258,16 @@ def main():
|
|
|
244
258
|
right = min(width, int(rect["x"] + rect["width"] + margin + 0.999))
|
|
245
259
|
bottom = min(height, int(rect["y"] + rect["height"] + margin + 0.999))
|
|
246
260
|
if right <= left or bottom <= top:
|
|
247
|
-
|
|
248
|
-
return next_step()
|
|
261
|
+
return None
|
|
249
262
|
cropped = pixbuf.new_subpixbuf(
|
|
250
263
|
int(left * scale), int(top * scale), int((right - left) * scale), int((bottom - top) * scale)
|
|
251
264
|
).copy()
|
|
252
|
-
save(
|
|
265
|
+
save(name, cropped, is_snapshot)
|
|
266
|
+
return os.path.join(args.out, f"{args.prefix}{name}.png")
|
|
267
|
+
|
|
268
|
+
def snap_element(step, rect):
|
|
269
|
+
if not crop(step["value"], rect):
|
|
270
|
+
step_failed(f"snap:{step['value']} failed: element is outside the window")
|
|
253
271
|
next_step()
|
|
254
272
|
|
|
255
273
|
def point_at(step, rect):
|
|
@@ -313,6 +331,72 @@ def main():
|
|
|
313
331
|
def next_step():
|
|
314
332
|
GLib.idle_add(run_step)
|
|
315
333
|
|
|
334
|
+
def annotate(step):
|
|
335
|
+
base = f"annotate-{step['value']}"
|
|
336
|
+
images = {}
|
|
337
|
+
memory = {"draft": None}
|
|
338
|
+
|
|
339
|
+
def later(callback, ms):
|
|
340
|
+
GLib.timeout_add(ms, lambda: (callback(), False)[1])
|
|
341
|
+
|
|
342
|
+
def again():
|
|
343
|
+
later(poll, step["pollMs"])
|
|
344
|
+
|
|
345
|
+
def restart():
|
|
346
|
+
draft = memory["draft"]
|
|
347
|
+
code = (f"window.{step['restoreKey']} = {json.dumps(draft)};" if draft else "") + step["start"]
|
|
348
|
+
evaluate(code, lambda _: again(), lambda _: again())
|
|
349
|
+
|
|
350
|
+
def grab(captures):
|
|
351
|
+
def shoot():
|
|
352
|
+
for capture in captures:
|
|
353
|
+
path = crop(f"{base}-{capture['id']}", capture["rect"], False)
|
|
354
|
+
if path:
|
|
355
|
+
images[capture["id"]] = os.path.basename(path)
|
|
356
|
+
evaluate(step["show"], lambda _: again(), lambda _: again())
|
|
357
|
+
|
|
358
|
+
evaluate(step["hide"], lambda _: later(shoot, ANNOTATE_SETTLE_MS), lambda _: shoot())
|
|
359
|
+
|
|
360
|
+
def finish_annotate(answer):
|
|
361
|
+
state["annotating"] = None
|
|
362
|
+
result = answer.get("result")
|
|
363
|
+
width = view.get_allocated_width()
|
|
364
|
+
answer.update(name=step["value"], scale=full_pixbuf().get_width() / width if width else 1)
|
|
365
|
+
if answer.get("status") != "done" or not result:
|
|
366
|
+
emit("annotate", answer)
|
|
367
|
+
return next_step()
|
|
368
|
+
save(base, full_pixbuf(), False)
|
|
369
|
+
answer["image"] = f"{args.prefix}{base}.png"
|
|
370
|
+
|
|
371
|
+
def crops():
|
|
372
|
+
for element_id, element in result["elements"].items():
|
|
373
|
+
path = crop(f"{base}-{element_id}", element["rect"], False) if element.get("isLive") else None
|
|
374
|
+
element["image"] = os.path.basename(path) if path else images.get(element_id)
|
|
375
|
+
emit("annotate", answer)
|
|
376
|
+
evaluate(step["dispose"], lambda _: next_step(), lambda _: next_step())
|
|
377
|
+
|
|
378
|
+
evaluate(step["cleanup"], lambda _: later(crops, ANNOTATE_SETTLE_MS * 2), lambda _: crops())
|
|
379
|
+
|
|
380
|
+
def poll():
|
|
381
|
+
def on_value(text):
|
|
382
|
+
if text == "missing":
|
|
383
|
+
return restart()
|
|
384
|
+
if not text or text == "null":
|
|
385
|
+
return again()
|
|
386
|
+
answer = json.loads(text)
|
|
387
|
+
if answer.get("status") != "open":
|
|
388
|
+
return finish_annotate(answer)
|
|
389
|
+
if answer.get("draft"):
|
|
390
|
+
memory["draft"] = answer["draft"]
|
|
391
|
+
if answer.get("captures"):
|
|
392
|
+
return grab(answer["captures"])
|
|
393
|
+
again()
|
|
394
|
+
|
|
395
|
+
evaluate(step["check"], on_value, lambda _: again())
|
|
396
|
+
|
|
397
|
+
state["annotating"] = step["value"]
|
|
398
|
+
evaluate(step["start"], lambda _: poll(), lambda error: (step_failed(f"annotate failed: {error}"), next_step()))
|
|
399
|
+
|
|
316
400
|
def run_step():
|
|
317
401
|
if state["index"] >= len(steps):
|
|
318
402
|
return finish()
|
|
@@ -328,6 +412,8 @@ def main():
|
|
|
328
412
|
next_step()
|
|
329
413
|
elif kind == "film":
|
|
330
414
|
film(step)
|
|
415
|
+
elif kind == "annotate":
|
|
416
|
+
annotate(step)
|
|
331
417
|
elif kind in ("js", "jsnow"):
|
|
332
418
|
evaluate(value, lambda _: None, lambda error: None if UNSUPPORTED_RESULT in error else emit("error", f"js failed: {error}"))
|
|
333
419
|
if kind == "js":
|
|
@@ -364,9 +450,9 @@ def main():
|
|
|
364
450
|
elif kind in ("hover", "click"):
|
|
365
451
|
with_rect(step, lambda rect: point_at(step, rect))
|
|
366
452
|
elif kind == "key":
|
|
367
|
-
press_all([(keyval_for(step["key"]), step["modifiers"])])
|
|
453
|
+
press_all(f"key:{value}", [(keyval_for(step["key"]), step["modifiers"])])
|
|
368
454
|
elif kind == "type":
|
|
369
|
-
press_all([(Gdk.unicode_to_keyval(ord(character)), ()) for character in value])
|
|
455
|
+
press_all("type", [(Gdk.unicode_to_keyval(ord(character)), ()) for character in value])
|
|
370
456
|
return False
|
|
371
457
|
|
|
372
458
|
def start_steps():
|
|
@@ -386,6 +472,8 @@ def main():
|
|
|
386
472
|
GLib.timeout_add(args.load_settle_ms, start_steps)
|
|
387
473
|
|
|
388
474
|
def on_load_failed(_view, _event, uri, error):
|
|
475
|
+
if error.matches(WebKit2.NetworkError.quark(), WebKit2.NetworkError.CANCELLED):
|
|
476
|
+
return False
|
|
389
477
|
emit("error", f"failed to load {uri}: {error.message}")
|
|
390
478
|
return finish(failed=True)
|
|
391
479
|
|
|
@@ -393,10 +481,21 @@ def main():
|
|
|
393
481
|
emit("error", f"timed out after {args.timeout_ms} ms")
|
|
394
482
|
return finish(failed=True)
|
|
395
483
|
|
|
484
|
+
def on_close(*_):
|
|
485
|
+
if state.get("annotating"):
|
|
486
|
+
emit("annotate", {"name": state["annotating"], "status": "cancelled", "result": None})
|
|
487
|
+
skipped = [step for step in steps[state["index"]:] if not step.get("isInternal")]
|
|
488
|
+
if skipped:
|
|
489
|
+
emit("error", f"window closed — {len(skipped)} step(s) not run")
|
|
490
|
+
finish(failed=bool(skipped))
|
|
491
|
+
return True
|
|
492
|
+
|
|
493
|
+
window.connect("delete-event", on_close)
|
|
396
494
|
view.connect("load-changed", on_load_changed)
|
|
397
495
|
view.connect("load-failed", on_load_failed)
|
|
398
496
|
view.load_uri(args.url)
|
|
399
|
-
|
|
497
|
+
if args.timeout_ms > 0:
|
|
498
|
+
GLib.timeout_add(args.timeout_ms, on_timeout)
|
|
400
499
|
Gtk.main()
|
|
401
500
|
|
|
402
501
|
sys.stdout.flush()
|
package/src/index.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export type Step =
|
|
|
13
13
|
| `film:${string}:${number}/${number}`
|
|
14
14
|
| `film:${string}:${number}@${string}`
|
|
15
15
|
| `film:${string}:${number}/${number}@${string}`
|
|
16
|
+
| "annotate"
|
|
17
|
+
| `annotate:${string}`
|
|
16
18
|
| "offline"
|
|
17
19
|
| "online"
|
|
18
20
|
| `latency:${number}`
|
|
@@ -53,6 +55,7 @@ export interface PreviewConfig {
|
|
|
53
55
|
headed?: boolean;
|
|
54
56
|
keepStorage?: boolean;
|
|
55
57
|
waitTimeout?: number;
|
|
58
|
+
timeout?: number;
|
|
56
59
|
snapMargin?: number;
|
|
57
60
|
compare?: boolean;
|
|
58
61
|
updateBaseline?: boolean;
|
|
@@ -63,6 +66,7 @@ export interface PreviewConfig {
|
|
|
63
66
|
globals?: Record<string, unknown>;
|
|
64
67
|
locale?: string;
|
|
65
68
|
macros?: Record<string, Step[] | string[]>;
|
|
69
|
+
annotate?: { private?: string[] }
|
|
66
70
|
failOnError?: boolean;
|
|
67
71
|
}
|
|
68
72
|
|
|
@@ -88,6 +92,7 @@ export interface MockResponse {
|
|
|
88
92
|
export type InitEntry = string | { file: string }
|
|
89
93
|
|
|
90
94
|
export interface PreviewOptions extends PreviewConfig {
|
|
95
|
+
private?: string | string[];
|
|
91
96
|
target?: string;
|
|
92
97
|
steps?: Step[] | string[];
|
|
93
98
|
initFile?: string | string[];
|
|
@@ -96,11 +101,56 @@ export interface PreviewOptions extends PreviewConfig {
|
|
|
96
101
|
log?: { info: (message: string) => void; error: (message: string) => void }
|
|
97
102
|
}
|
|
98
103
|
|
|
104
|
+
export interface AnnotationSource {
|
|
105
|
+
file: string;
|
|
106
|
+
line: number | null;
|
|
107
|
+
column: number | null;
|
|
108
|
+
how: "debug-source" | "debug-stack" | "vue-file";
|
|
109
|
+
confidence?: "exact" | "nearest" | "file";
|
|
110
|
+
isMapped?: false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface AnnotationElement {
|
|
114
|
+
selector: string;
|
|
115
|
+
selectorQuality: "stable" | "positional";
|
|
116
|
+
tag: string;
|
|
117
|
+
role: string | null;
|
|
118
|
+
name: string | null;
|
|
119
|
+
text: string | null;
|
|
120
|
+
label: string;
|
|
121
|
+
component: {
|
|
122
|
+
name: string | null;
|
|
123
|
+
framework: "react" | "vue";
|
|
124
|
+
chain: { name: string; usedAt: AnnotationSource | null }[];
|
|
125
|
+
source: AnnotationSource | null;
|
|
126
|
+
} | null;
|
|
127
|
+
box: { margin: string; padding: string; border: string; borderRadius: string }
|
|
128
|
+
style: { display: string; font: string; color: string; background: string; opacity: string; gap: string }
|
|
129
|
+
rect: { x: number; y: number; width: number; height: number }
|
|
130
|
+
url: string;
|
|
131
|
+
isLive: boolean;
|
|
132
|
+
isPrivate: boolean;
|
|
133
|
+
image: string | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface Annotation {
|
|
137
|
+
version: 1;
|
|
138
|
+
name: string;
|
|
139
|
+
status: "done" | "cancelled";
|
|
140
|
+
page: { url: string; route: string; engine: Exclude<Engine, "auto">; size: { width: number; height: number }; scale: number; takenAt: string }
|
|
141
|
+
image: string | null;
|
|
142
|
+
notes: { id: number; text: string; elements: string[]; url: string; isFrozen: boolean }[];
|
|
143
|
+
maskedCount: number;
|
|
144
|
+
generalNote: string | null;
|
|
145
|
+
elements: Record<string, AnnotationElement>;
|
|
146
|
+
}
|
|
147
|
+
|
|
99
148
|
export interface PreviewResult {
|
|
100
149
|
engine: Exclude<Engine, "auto">;
|
|
101
150
|
url: string;
|
|
102
151
|
sizes: string[];
|
|
103
152
|
saved: string[];
|
|
153
|
+
annotations: (Annotation | { name: string; status: "cancelled"; result: null })[];
|
|
104
154
|
consoleErrors: number;
|
|
105
155
|
failedAsserts: number;
|
|
106
156
|
report?: string;
|
package/src/index.mjs
CHANGED
|
@@ -11,7 +11,11 @@ import { resolveInit, wrapInit } from "#src/page/init.mjs";
|
|
|
11
11
|
import { compileMock, mockScript, resolveMockFiles } from "#src/page/mock.mjs";
|
|
12
12
|
import { startServer } from "#src/core/server.mjs";
|
|
13
13
|
import { watchFiles } from "#src/core/watch.mjs";
|
|
14
|
-
import { expandMacros, parseSize, parseSteps } from "#src/core/steps.mjs";
|
|
14
|
+
import { expandMacros, parseSize, parseSteps, runTimeoutMs } from "#src/core/steps.mjs";
|
|
15
|
+
import { loadAnnotateKeys, saveAnnotateKeys } from "#src/core/annotate-settings.mjs";
|
|
16
|
+
import { writeAnnotationReport } from "#src/core/annotate-report.mjs";
|
|
17
|
+
import { createSourceResolver } from "#src/core/source-map.mjs";
|
|
18
|
+
import { annotateStep, formatAnnotation } from "#src/page/annotate/index.mjs";
|
|
15
19
|
import { checkLatency, networkScript } from "#src/page/network.mjs";
|
|
16
20
|
import { resolveTauri, tauriScript } from "#src/page/tauri.mjs";
|
|
17
21
|
|
|
@@ -50,13 +54,27 @@ const checkColorScheme = value => {
|
|
|
50
54
|
return value;
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
const resolveSources = async (annotation, baseDir) => {
|
|
58
|
+
const resolve = createSourceResolver(baseDir);
|
|
59
|
+
for (const element of Object.values(annotation.result?.elements ?? {})) {
|
|
60
|
+
const { component } = element;
|
|
61
|
+
if (!component) continue;
|
|
62
|
+
const confidence = component.source?.confidence;
|
|
63
|
+
component.source = component.source && { ...await resolve(component.source), confidence }
|
|
64
|
+
component.chain = await Promise.all(component.chain.map(async item => ({ name: item.name, usedAt: await resolve(item.usedAt) })));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
53
68
|
export const preview = async (options = {}) => {
|
|
54
69
|
const config = options.config === false ? {} : await loadConfig(options.config);
|
|
55
70
|
const merged = { ...DEFAULTS, ...config, ...Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) }
|
|
56
71
|
const baseDir = config.configDir ?? process.cwd();
|
|
57
72
|
const engine = resolveEngine(merged.engine);
|
|
58
73
|
const macros = { ...config.macros, ...options.macros }
|
|
59
|
-
const
|
|
74
|
+
const parsedSteps = parseSteps(expandMacros(merged.steps.length ? merged.steps : DEFAULTS.steps, macros));
|
|
75
|
+
const savedKeys = parsedSteps.some(step => step.kind === "annotate") ? loadAnnotateKeys() : null;
|
|
76
|
+
const privateSelectors = [...(config.annotate?.private ?? []), ...[].concat(options.private ?? [])].map(String);
|
|
77
|
+
const steps = parsedSteps.map(step => step.kind === "annotate" ? annotateStep(step.value, savedKeys, privateSelectors) : step);
|
|
60
78
|
const out = path.resolve(baseDir, merged.out);
|
|
61
79
|
const url = resolveUrl(merged.target ?? merged.url ?? "/", merged.baseUrl);
|
|
62
80
|
const sizes = parseSizes(merged.size);
|
|
@@ -112,7 +130,8 @@ export const preview = async (options = {}) => {
|
|
|
112
130
|
const stopServer = await startServer(server);
|
|
113
131
|
try {
|
|
114
132
|
const prefix = merged.prefix ?? `${engine}-`;
|
|
115
|
-
const result = { saved: [], failed: false }
|
|
133
|
+
const result = { saved: [], failed: false, annotations: [] }
|
|
134
|
+
const hasAnnotate = steps.some(step => step.kind === "annotate");
|
|
116
135
|
for (const size of sizes) {
|
|
117
136
|
if (sizes.length > 1) output.log.info(`size ${size}`);
|
|
118
137
|
const run = await runEngine(engine, {
|
|
@@ -127,12 +146,37 @@ export const preview = async (options = {}) => {
|
|
|
127
146
|
prefix: sizes.length > 1 ? `${prefix}${size}-` : prefix,
|
|
128
147
|
scale: merged.scale === undefined ? undefined : Number(merged.scale),
|
|
129
148
|
snapMargin,
|
|
130
|
-
isHeaded: !!merged.headed,
|
|
149
|
+
isHeaded: merged.headed === undefined ? hasAnnotate : !!merged.headed,
|
|
131
150
|
isStorageKept: !!merged.keepStorage,
|
|
132
151
|
waitForTimeoutMs: Number(merged.waitTimeout ?? 10000),
|
|
152
|
+
timeoutMs: merged.timeout !== undefined ? Number(merged.timeout)
|
|
153
|
+
: hasAnnotate ? 0
|
|
154
|
+
: runTimeoutMs([...steps, consoleStep], Number(merged.waitTimeout ?? 10000)),
|
|
133
155
|
log: output.log
|
|
134
156
|
});
|
|
135
157
|
result.saved.push(...run.saved);
|
|
158
|
+
for (const annotation of run.annotations ?? []) {
|
|
159
|
+
try {
|
|
160
|
+
await resolveSources(annotation, baseDir);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
output.log.error(`annotate: source files not resolved — ${error.message}`);
|
|
163
|
+
}
|
|
164
|
+
if (annotation.keys) {
|
|
165
|
+
try {
|
|
166
|
+
output.log.info(`annotate keys saved → ${saveAnnotateKeys(annotation.keys)}`);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
output.log.error(`annotate: keys not saved — ${error.message}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
delete annotation.keys;
|
|
172
|
+
for (const line of formatAnnotation(annotation)) output.log.info(line);
|
|
173
|
+
if (annotation.status === "done") {
|
|
174
|
+
const files = writeAnnotationReport({ annotation, out, prefix: sizes.length > 1 ? `${prefix}${size}-` : prefix, engine, url, baseUrl: merged.baseUrl, size: parseSize(size) });
|
|
175
|
+
output.log.info(` → ${files.json}`);
|
|
176
|
+
output.log.info(` → ${files.markdown}`);
|
|
177
|
+
result.annotations.push(files.report);
|
|
178
|
+
} else result.annotations.push(annotation);
|
|
179
|
+
}
|
|
136
180
|
for (const film of run.films ?? []) {
|
|
137
181
|
try {
|
|
138
182
|
output.log.info(stitchFilm(film, snapMargin));
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function pageDom() {
|
|
2
|
+
const SVG = "http://www.w3.org/2000/svg";
|
|
3
|
+
const TOKEN = /<\/([\w-]+)\s*>|<([\w-]+)((?:\s+[\w:-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'))?)*)\s*(\/?)>|([^<]+)/g;
|
|
4
|
+
const ATTRIBUTE = /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
|
|
5
|
+
const VOID = new Set(["br", "hr", "img", "input", "link", "meta"]);
|
|
6
|
+
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: "\"", "#39": "'" }
|
|
7
|
+
const decode = text => text.replace(/&(amp|lt|gt|quot|#39);/g, (_, name) => ENTITIES[name]);
|
|
8
|
+
|
|
9
|
+
const fragment = markup => {
|
|
10
|
+
const root = document.createDocumentFragment();
|
|
11
|
+
const stack = [root];
|
|
12
|
+
for (const [, closing, tag, attributes, selfClosing, text] of markup.matchAll(TOKEN)) {
|
|
13
|
+
const parent = stack.at(-1);
|
|
14
|
+
if (closing) {
|
|
15
|
+
if (stack.length > 1) stack.pop();
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (text !== undefined) {
|
|
19
|
+
if (text.trim()) parent.append(decode(text));
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const isSvg = tag === "svg" || parent.namespaceURI === SVG;
|
|
23
|
+
const element = isSvg ? document.createElementNS(SVG, tag) : document.createElement(tag);
|
|
24
|
+
for (const [, name, double, single] of attributes.matchAll(ATTRIBUTE)) element.setAttribute(name, decode(double ?? single ?? ""));
|
|
25
|
+
parent.append(element);
|
|
26
|
+
if (!selfClosing && !VOID.has(tag)) stack.push(element);
|
|
27
|
+
}
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const adoptStyles = (root, styles) => {
|
|
32
|
+
try {
|
|
33
|
+
const sheet = new CSSStyleSheet();
|
|
34
|
+
sheet.replaceSync(styles);
|
|
35
|
+
root.adoptedStyleSheets = [sheet];
|
|
36
|
+
} catch {
|
|
37
|
+
const style = document.createElement("style");
|
|
38
|
+
style.textContent = styles;
|
|
39
|
+
root.append(style);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { fragment, adoptStyles }
|
|
44
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const formatAnnotation = annotation => {
|
|
2
|
+
const lines = [];
|
|
3
|
+
const { name, status, result } = annotation;
|
|
4
|
+
if (status !== "done" || !result) return [`annotate ${name}: cancelled`];
|
|
5
|
+
const elementCount = Object.keys(result.elements).length;
|
|
6
|
+
lines.push(`annotate ${name}: done · ${result.notes.length} note(s) · ${elementCount} element(s)${result.generalNote ? " · 1 general note" : ""}${result.maskedCount ? ` · ${result.maskedCount} private element(s) hidden` : ""}`);
|
|
7
|
+
for (const note of result.notes) {
|
|
8
|
+
const targets = note.elements.map(id => {
|
|
9
|
+
const element = result.elements[id];
|
|
10
|
+
if (!element) return id;
|
|
11
|
+
const source = element.component?.source;
|
|
12
|
+
const where = source ? ` (${source.file}${source.line ? `:${source.line}` : ""})` : "";
|
|
13
|
+
return element.component?.name ? `${element.component.name} ${element.label}${where}` : `${element.label}${where}`;
|
|
14
|
+
});
|
|
15
|
+
const where = targets.length === 1 ? targets[0] : `${targets.length} elements: ${targets.join(", ")}`;
|
|
16
|
+
lines.push(` ${note.id}. ${where} — ${note.text || "(no text)"}`);
|
|
17
|
+
for (const id of note.elements) lines.push(` ${id}: ${result.elements[id]?.selector}`);
|
|
18
|
+
}
|
|
19
|
+
if (result.generalNote) lines.push(` general: ${result.generalNote}`);
|
|
20
|
+
return lines;
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const icon = paths => `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
2
|
+
export const ICONS = {
|
|
3
|
+
grip: icon("<circle cx='5' cy='9' r='1'/><circle cx='12' cy='9' r='1'/><circle cx='19' cy='9' r='1'/><circle cx='5' cy='15' r='1'/><circle cx='12' cy='15' r='1'/><circle cx='19' cy='15' r='1'/>"),
|
|
4
|
+
open: icon("<rect width='18' height='18' x='3' y='3' rx='2'/><path d='M9 3v18'/><path d='m14 9 3 3-3 3'/>"),
|
|
5
|
+
close: icon("<rect width='18' height='18' x='3' y='3' rx='2'/><path d='M9 3v18'/><path d='m16 15-3-3 3-3'/>"),
|
|
6
|
+
pick: icon("<path d='m12 9-8.414 8.414A2 2 0 0 0 3 18.828v1.344a2 2 0 0 1-.586 1.414A2 2 0 0 1 3.828 21h1.344a2 2 0 0 0 1.414-.586L15 12'/><path d='m18 9 .4.4a1 1 0 1 1-3 3l-3.8-3.8a1 1 0 1 1 3-3l.4.4 3.4-3.4a1 1 0 1 1 3 3z'/><path d='m2 22 .414-.414'/>"),
|
|
7
|
+
freeze: icon("<path d='m10 20-1.25-2.5L6 18'/><path d='M10 4 8.75 6.5 6 6'/><path d='m14 20 1.25-2.5L18 18'/><path d='m14 4 1.25 2.5L18 6'/><path d='m17 21-3-6h-4'/><path d='m17 3-3 6 1.5 3'/><path d='M2 12h6.5L10 9'/><path d='m20 10-1.5 2 1.5 2'/><path d='M22 12h-6.5L14 15'/><path d='m4 10 1.5 2L4 14'/><path d='m7 21 3-6-1.5-3'/><path d='m7 3 3 6h4'/>"),
|
|
8
|
+
notes: icon("<path d='M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z'/><path d='M7 11h10'/><path d='M7 15h6'/><path d='M7 7h8'/>"),
|
|
9
|
+
keys: icon("<path d='M10 8h.01'/><path d='M12 12h.01'/><path d='M14 8h.01'/><path d='M16 12h.01'/><path d='M18 8h.01'/><path d='M6 8h.01'/><path d='M7 16h10'/><path d='M8 12h.01'/><rect width='20' height='16' x='2' y='4' rx='2'/>"),
|
|
10
|
+
done: icon("<path d='M20 6 9 17l-5-5'/>"),
|
|
11
|
+
cancel: icon("<path d='M18 6 6 18'/><path d='m6 6 12 12'/>")
|
|
12
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { pageDom } from "#src/page/annotate/dom.mjs";
|
|
2
|
+
import { ICONS } from "#src/page/annotate/icons.mjs";
|
|
3
|
+
import { pageKeys } from "#src/page/annotate/keys.mjs";
|
|
4
|
+
import { MARKUP } from "#src/page/annotate/markup.mjs";
|
|
5
|
+
import { annotatePanel } from "#src/page/annotate/panel.mjs";
|
|
6
|
+
import { pageReact } from "#src/page/annotate/react.mjs";
|
|
7
|
+
import { pageSelectors } from "#src/page/annotate/selector.mjs";
|
|
8
|
+
import { STYLES } from "#src/page/annotate/styles.mjs";
|
|
9
|
+
import { pageVue } from "#src/page/annotate/vue.mjs";
|
|
10
|
+
|
|
11
|
+
export { formatAnnotation } from "#src/page/annotate/format.mjs";
|
|
12
|
+
|
|
13
|
+
export const ANNOTATE_KEY = "__webviewPreviewAnnotate";
|
|
14
|
+
export const ANNOTATE_POLL_MS = 250;
|
|
15
|
+
export const ANNOTATE_CLEANUP = `window.${ANNOTATE_KEY}?.cleanup?.()`;
|
|
16
|
+
|
|
17
|
+
export const annotateStart = (name, keys = null, privateSelectors = []) => `(${annotatePanel})(${JSON.stringify({ key: ANNOTATE_KEY, name, markup: MARKUP, styles: STYLES, icons: ICONS })}, {
|
|
18
|
+
keys: (${pageKeys})(${JSON.stringify(keys)}),
|
|
19
|
+
selectors: (${pageSelectors})(${JSON.stringify(privateSelectors)}),
|
|
20
|
+
react: (${pageReact})(),
|
|
21
|
+
vue: (${pageVue})(),
|
|
22
|
+
dom: (${pageDom})()
|
|
23
|
+
})`;
|
|
24
|
+
|
|
25
|
+
export const ANNOTATE_CHECK = `(() => {
|
|
26
|
+
const state = window.${ANNOTATE_KEY};
|
|
27
|
+
if (!state) return "missing";
|
|
28
|
+
if (state.status === "open") {
|
|
29
|
+
const draft = state.version !== state.sentVersion ? (state.sentVersion = state.version, state.draft()) : null;
|
|
30
|
+
const captures = state.captures.splice(0);
|
|
31
|
+
if (captures.length) state.capturing = captures;
|
|
32
|
+
return draft || captures.length ? JSON.stringify({ status: "open", draft, captures }) : null;
|
|
33
|
+
}
|
|
34
|
+
return JSON.stringify({ status: state.status, result: state.result, keys: state.keys });
|
|
35
|
+
})()`;
|
|
36
|
+
|
|
37
|
+
export const annotateRestore = draft => `window.${ANNOTATE_KEY}Draft = ${JSON.stringify(draft)};`;
|
|
38
|
+
|
|
39
|
+
export const annotateStep = (name, keys, privateSelectors = []) => ({
|
|
40
|
+
kind: "annotate",
|
|
41
|
+
value: name,
|
|
42
|
+
start: annotateStart(name, keys, privateSelectors),
|
|
43
|
+
check: ANNOTATE_CHECK,
|
|
44
|
+
hide: `window.${ANNOTATE_KEY}?.setHidden?.(true)`,
|
|
45
|
+
show: `window.${ANNOTATE_KEY}?.setHidden?.(false)`,
|
|
46
|
+
cleanup: ANNOTATE_CLEANUP,
|
|
47
|
+
dispose: `window.${ANNOTATE_KEY}?.dispose?.()`,
|
|
48
|
+
restoreKey: `${ANNOTATE_KEY}Draft`,
|
|
49
|
+
pollMs: ANNOTATE_POLL_MS
|
|
50
|
+
});
|