webview-preview 1.1.2 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +31 -1
- package/README.md +120 -3
- package/bin/webview-preview.mjs +7 -1
- package/package.json +15 -4
- package/src/core/annotate-report.mjs +67 -0
- package/src/core/annotate-settings.mjs +30 -0
- package/src/core/server.mjs +12 -1
- package/src/core/source-map.mjs +140 -0
- package/src/core/steps.mjs +6 -1
- package/src/engines/playwright.mjs +88 -3
- package/src/engines/webkitgtk.mjs +7 -2
- package/src/engines/webkitgtk.py +128 -6
- package/src/index.d.ts +51 -0
- package/src/index.mjs +60 -7
- package/src/page/annotate/dom.mjs +44 -0
- package/src/page/annotate/format.mjs +22 -0
- package/src/page/annotate/icons.mjs +13 -0
- package/src/page/annotate/index.mjs +52 -0
- package/src/page/annotate/keys.mjs +33 -0
- package/src/page/annotate/markup.mjs +38 -0
- package/src/page/annotate/panel.mjs +787 -0
- package/src/page/annotate/react.mjs +55 -0
- package/src/page/annotate/selector.mjs +130 -0
- package/src/page/annotate/styles.mjs +81 -0
- package/src/page/annotate/vue.mjs +53 -0
- package/src/snapshots/clean.mjs +1 -1
|
@@ -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, onAnnotation, 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,92 @@ 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 report = annotation => {
|
|
85
|
+
annotations.push(annotation);
|
|
86
|
+
onAnnotation?.(annotation);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const annotate = async step => {
|
|
90
|
+
let round = 1;
|
|
91
|
+
const base = () => `${prefix}annotate-${step.value}${round > 1 ? `-${round}` : ""}`;
|
|
92
|
+
let images = {}
|
|
93
|
+
let draft = null;
|
|
94
|
+
let lastUrl = null;
|
|
95
|
+
await page.evaluate(step.start);
|
|
96
|
+
for (;;) {
|
|
97
|
+
if (page.isClosed()) {
|
|
98
|
+
report({ name: step.value, status: "cancelled", result: null, round });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (Date.now() > deadline) {
|
|
102
|
+
report({ name: step.value, status: "cancelled", result: null, round });
|
|
103
|
+
throw new Error(`timed out after ${timeoutMs} ms`);
|
|
104
|
+
}
|
|
105
|
+
const text = await page.evaluate(step.check).catch(() => null);
|
|
106
|
+
const answer = text && text !== "missing" ? JSON.parse(text) : null;
|
|
107
|
+
if (text === "missing") {
|
|
108
|
+
for (const earlier of step.replay ?? []) {
|
|
109
|
+
try {
|
|
110
|
+
await runStep(earlier);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
log.error(`replay: ${error.message.split("\n")[0]}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (lastUrl && page.url() !== lastUrl) {
|
|
116
|
+
await page.evaluate(`location.href = ${JSON.stringify(lastUrl)}`).catch(() => undefined);
|
|
117
|
+
await page.waitForTimeout(JS_SETTLE_MS);
|
|
118
|
+
}
|
|
119
|
+
await page.evaluate(`${draft ? `window.${step.restoreKey} = ${JSON.stringify(draft)};` : ""}${step.start}`).catch(() => undefined);
|
|
120
|
+
} else if (answer?.status === "open") {
|
|
121
|
+
if (answer.url) lastUrl = answer.url;
|
|
122
|
+
if (answer.draft) draft = answer.draft;
|
|
123
|
+
if (answer.captures?.length) {
|
|
124
|
+
await page.evaluate(step.hide).catch(() => undefined);
|
|
125
|
+
await page.waitForTimeout(80);
|
|
126
|
+
for (const capture of answer.captures) {
|
|
127
|
+
const image = await cutOut(capture.rect, path.join(out, `${base()}-${capture.id}.png`));
|
|
128
|
+
if (image) images[capture.id] = image;
|
|
129
|
+
}
|
|
130
|
+
await page.evaluate(step.show).catch(() => undefined);
|
|
131
|
+
}
|
|
132
|
+
} else if (answer) {
|
|
133
|
+
const annotation = { ...answer, name: step.value, round, scale: scale ?? 1 }
|
|
134
|
+
const isSent = annotation.status === "sent";
|
|
135
|
+
if ((annotation.status === "done" || isSent) && annotation.result) {
|
|
136
|
+
await page.screenshot({ path: path.join(out, `${base()}.png`) });
|
|
137
|
+
annotation.image = `${base()}.png`;
|
|
138
|
+
await page.evaluate(step.cleanup).catch(() => undefined);
|
|
139
|
+
await page.waitForTimeout(160);
|
|
140
|
+
for (const [id, element] of Object.entries(annotation.result.elements)) {
|
|
141
|
+
element.image = element.isLive
|
|
142
|
+
? await cutOut(element.rect, path.join(out, `${base()}-${id}.png`)) ?? images[id] ?? null
|
|
143
|
+
: images[id] ?? null;
|
|
144
|
+
}
|
|
145
|
+
if (!isSent) await page.evaluate(step.dispose).catch(() => undefined);
|
|
146
|
+
}
|
|
147
|
+
report(annotation);
|
|
148
|
+
if (!isSent) return;
|
|
149
|
+
round += 1;
|
|
150
|
+
images = {}
|
|
151
|
+
draft = null;
|
|
152
|
+
await page.evaluate(step.resume).catch(() => undefined);
|
|
153
|
+
}
|
|
154
|
+
await new Promise(resolve => setTimeout(resolve, step.pollMs));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
74
158
|
const runStep = async step => {
|
|
75
159
|
const { kind, value } = step;
|
|
160
|
+
if (kind === "annotate") return annotate(step);
|
|
76
161
|
if (kind === "snap") {
|
|
77
162
|
const file = path.join(out, `${prefix}${value}.png`);
|
|
78
163
|
const clip = step.selector ? clipRect(await elementRect(step), snapMargin, page.viewportSize()) : undefined;
|
|
@@ -133,5 +218,5 @@ export const runPlaywright = async ({
|
|
|
133
218
|
await browser.close();
|
|
134
219
|
}
|
|
135
220
|
|
|
136
|
-
return { saved, failed, films }
|
|
221
|
+
return { saved, failed, films, annotations }
|
|
137
222
|
}
|
|
@@ -13,7 +13,7 @@ export const runWebkitGtk = ({
|
|
|
13
13
|
url, steps, init = [], size, colorScheme,
|
|
14
14
|
isMotionReduced = false, out, prefix, snapMargin = 8,
|
|
15
15
|
isHeaded = false, isStorageKept = false,
|
|
16
|
-
waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, log
|
|
16
|
+
waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, onAnnotation, log
|
|
17
17
|
}) => new Promise((resolve, reject) => {
|
|
18
18
|
if (!isWebkitGtkAvailable()) {
|
|
19
19
|
reject(new Error("WebKitGTK engine needs Linux with python3-gi and WebKit2 4.1 (gir1.2-webkit2-4.1)"));
|
|
@@ -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,10 @@ 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") {
|
|
66
|
+
annotations.push(value);
|
|
67
|
+
onAnnotation?.(value);
|
|
68
|
+
}
|
|
64
69
|
else if (kind === "print") log.info(value);
|
|
65
70
|
else if (kind === "error") log.error(value);
|
|
66
71
|
else if (kind === "init-errors") reportInitErrors(value, log);
|
|
@@ -73,5 +78,5 @@ export const runWebkitGtk = ({
|
|
|
73
78
|
}
|
|
74
79
|
});
|
|
75
80
|
child.once("error", reject);
|
|
76
|
-
child.once("exit", code => resolve({ saved, failed: code !== 0, films }));
|
|
81
|
+
child.once("exit", code => resolve({ saved, failed: code !== 0, films, annotations }));
|
|
77
82
|
});
|
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 = {
|
|
@@ -118,6 +119,9 @@ def main():
|
|
|
118
119
|
return False
|
|
119
120
|
|
|
120
121
|
def step_failed(message):
|
|
122
|
+
if state.get("replaying"):
|
|
123
|
+
emit("error", f"replay: {message}")
|
|
124
|
+
return
|
|
121
125
|
state["failed"] = True
|
|
122
126
|
emit("error", message)
|
|
123
127
|
|
|
@@ -217,10 +221,11 @@ def main():
|
|
|
217
221
|
)
|
|
218
222
|
return window.get_pixbuf()
|
|
219
223
|
|
|
220
|
-
def save(name, pixbuf):
|
|
224
|
+
def save(name, pixbuf, is_snapshot=True):
|
|
221
225
|
path = os.path.join(args.out, f"{args.prefix}{name}.png")
|
|
222
226
|
pixbuf.savev(path, "png", [], [])
|
|
223
|
-
|
|
227
|
+
if is_snapshot:
|
|
228
|
+
emit("saved", path)
|
|
224
229
|
|
|
225
230
|
def evaluate(code, on_value, on_error):
|
|
226
231
|
def on_result(webview, result):
|
|
@@ -246,7 +251,7 @@ def main():
|
|
|
246
251
|
|
|
247
252
|
evaluate(step["rect"], on_value, lambda error: (step_failed(f"{step['kind']}:{step['value']} failed: {error}"), next_step()))
|
|
248
253
|
|
|
249
|
-
def
|
|
254
|
+
def crop(name, rect, is_snapshot=True):
|
|
250
255
|
pixbuf = full_pixbuf()
|
|
251
256
|
width, height = view.get_allocated_width(), view.get_allocated_height()
|
|
252
257
|
scale = pixbuf.get_width() / width
|
|
@@ -256,12 +261,16 @@ def main():
|
|
|
256
261
|
right = min(width, int(rect["x"] + rect["width"] + margin + 0.999))
|
|
257
262
|
bottom = min(height, int(rect["y"] + rect["height"] + margin + 0.999))
|
|
258
263
|
if right <= left or bottom <= top:
|
|
259
|
-
|
|
260
|
-
return next_step()
|
|
264
|
+
return None
|
|
261
265
|
cropped = pixbuf.new_subpixbuf(
|
|
262
266
|
int(left * scale), int(top * scale), int((right - left) * scale), int((bottom - top) * scale)
|
|
263
267
|
).copy()
|
|
264
|
-
save(
|
|
268
|
+
save(name, cropped, is_snapshot)
|
|
269
|
+
return os.path.join(args.out, f"{args.prefix}{name}.png")
|
|
270
|
+
|
|
271
|
+
def snap_element(step, rect):
|
|
272
|
+
if not crop(step["value"], rect):
|
|
273
|
+
step_failed(f"snap:{step['value']} failed: element is outside the window")
|
|
265
274
|
next_step()
|
|
266
275
|
|
|
267
276
|
def point_at(step, rect):
|
|
@@ -325,6 +334,98 @@ def main():
|
|
|
325
334
|
def next_step():
|
|
326
335
|
GLib.idle_add(run_step)
|
|
327
336
|
|
|
337
|
+
def annotate(step):
|
|
338
|
+
memory = state.setdefault("annotate_memory", {}).setdefault(
|
|
339
|
+
step["value"], {"draft": None, "round": 1, "images": {}, "url": None}
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def base():
|
|
343
|
+
return f"annotate-{step['value']}" + (f"-{memory['round']}" if memory["round"] > 1 else "")
|
|
344
|
+
|
|
345
|
+
def later(callback, ms):
|
|
346
|
+
GLib.timeout_add(ms, lambda: (callback(), False)[1])
|
|
347
|
+
|
|
348
|
+
def again():
|
|
349
|
+
later(poll, step["pollMs"])
|
|
350
|
+
|
|
351
|
+
def restart():
|
|
352
|
+
draft = memory["draft"]
|
|
353
|
+
code = (f"window.{step['restoreKey']} = {json.dumps(draft)};" if draft else "") + step["start"]
|
|
354
|
+
evaluate(code, lambda _: again(), lambda _: again())
|
|
355
|
+
|
|
356
|
+
def replay():
|
|
357
|
+
resume = {**step, "isResume": True}
|
|
358
|
+
steps[state["index"]:state["index"]] = [*step["replay"], resume]
|
|
359
|
+
state["replaying"] = True
|
|
360
|
+
next_step()
|
|
361
|
+
|
|
362
|
+
def go_back_then_restart():
|
|
363
|
+
url = memory["url"]
|
|
364
|
+
if not url:
|
|
365
|
+
return restart()
|
|
366
|
+
code = f"if (location.href !== {json.dumps(url)}) location.href = {json.dumps(url)};"
|
|
367
|
+
evaluate(code, lambda _: later(restart, args.js_settle_ms), lambda _: restart())
|
|
368
|
+
|
|
369
|
+
def grab(captures):
|
|
370
|
+
def shoot():
|
|
371
|
+
for capture in captures:
|
|
372
|
+
path = crop(f"{base()}-{capture['id']}", capture["rect"], False)
|
|
373
|
+
if path:
|
|
374
|
+
memory["images"][capture["id"]] = os.path.basename(path)
|
|
375
|
+
evaluate(step["show"], lambda _: again(), lambda _: again())
|
|
376
|
+
|
|
377
|
+
evaluate(step["hide"], lambda _: later(shoot, ANNOTATE_SETTLE_MS), lambda _: shoot())
|
|
378
|
+
|
|
379
|
+
def finish_annotate(answer):
|
|
380
|
+
is_sent = answer.get("status") == "sent"
|
|
381
|
+
if not is_sent:
|
|
382
|
+
state["annotating"] = None
|
|
383
|
+
result = answer.get("result")
|
|
384
|
+
width = view.get_allocated_width()
|
|
385
|
+
answer.update(name=step["value"], round=memory["round"], scale=full_pixbuf().get_width() / width if width else 1)
|
|
386
|
+
if answer.get("status") not in ("done", "sent") or not result:
|
|
387
|
+
emit("annotate", answer)
|
|
388
|
+
return next_step()
|
|
389
|
+
save(base(), full_pixbuf(), False)
|
|
390
|
+
answer["image"] = f"{args.prefix}{base()}.png"
|
|
391
|
+
|
|
392
|
+
def crops():
|
|
393
|
+
for element_id, element in result["elements"].items():
|
|
394
|
+
path = crop(f"{base()}-{element_id}", element["rect"], False) if element.get("isLive") else None
|
|
395
|
+
element["image"] = os.path.basename(path) if path else memory["images"].get(element_id)
|
|
396
|
+
emit("annotate", answer)
|
|
397
|
+
if not is_sent:
|
|
398
|
+
return evaluate(step["dispose"], lambda _: next_step(), lambda _: next_step())
|
|
399
|
+
memory.update(draft=None, images={}, round=memory["round"] + 1)
|
|
400
|
+
evaluate(step["resume"], lambda _: again(), lambda _: again())
|
|
401
|
+
|
|
402
|
+
evaluate(step["cleanup"], lambda _: later(crops, ANNOTATE_SETTLE_MS * 2), lambda _: crops())
|
|
403
|
+
|
|
404
|
+
def poll():
|
|
405
|
+
def on_value(text):
|
|
406
|
+
if text == "missing":
|
|
407
|
+
return replay() if step.get("replay") else restart()
|
|
408
|
+
if not text or text == "null":
|
|
409
|
+
return again()
|
|
410
|
+
answer = json.loads(text)
|
|
411
|
+
if answer.get("status") != "open":
|
|
412
|
+
return finish_annotate(answer)
|
|
413
|
+
if answer.get("url"):
|
|
414
|
+
memory["url"] = answer["url"]
|
|
415
|
+
if answer.get("draft"):
|
|
416
|
+
memory["draft"] = answer["draft"]
|
|
417
|
+
if answer.get("captures"):
|
|
418
|
+
return grab(answer["captures"])
|
|
419
|
+
again()
|
|
420
|
+
|
|
421
|
+
evaluate(step["check"], on_value, lambda _: again())
|
|
422
|
+
|
|
423
|
+
state["annotating"] = step["value"]
|
|
424
|
+
if step.get("isResume"):
|
|
425
|
+
state["replaying"] = False
|
|
426
|
+
return go_back_then_restart()
|
|
427
|
+
evaluate(step["start"], lambda _: poll(), lambda error: (step_failed(f"annotate failed: {error}"), next_step()))
|
|
428
|
+
|
|
328
429
|
def run_step():
|
|
329
430
|
if state["index"] >= len(steps):
|
|
330
431
|
return finish()
|
|
@@ -340,6 +441,8 @@ def main():
|
|
|
340
441
|
next_step()
|
|
341
442
|
elif kind == "film":
|
|
342
443
|
film(step)
|
|
444
|
+
elif kind == "annotate":
|
|
445
|
+
annotate(step)
|
|
343
446
|
elif kind in ("js", "jsnow"):
|
|
344
447
|
evaluate(value, lambda _: None, lambda error: None if UNSUPPORTED_RESULT in error else emit("error", f"js failed: {error}"))
|
|
345
448
|
if kind == "js":
|
|
@@ -398,6 +501,8 @@ def main():
|
|
|
398
501
|
GLib.timeout_add(args.load_settle_ms, start_steps)
|
|
399
502
|
|
|
400
503
|
def on_load_failed(_view, _event, uri, error):
|
|
504
|
+
if error.matches(WebKit2.NetworkError.quark(), WebKit2.NetworkError.CANCELLED):
|
|
505
|
+
return False
|
|
401
506
|
emit("error", f"failed to load {uri}: {error.message}")
|
|
402
507
|
return finish(failed=True)
|
|
403
508
|
|
|
@@ -405,6 +510,16 @@ def main():
|
|
|
405
510
|
emit("error", f"timed out after {args.timeout_ms} ms")
|
|
406
511
|
return finish(failed=True)
|
|
407
512
|
|
|
513
|
+
def on_close(*_):
|
|
514
|
+
if state.get("annotating"):
|
|
515
|
+
emit("annotate", {"name": state["annotating"], "status": "cancelled", "result": None})
|
|
516
|
+
skipped = [step for step in steps[state["index"]:] if not step.get("isInternal")]
|
|
517
|
+
if skipped:
|
|
518
|
+
emit("error", f"window closed — {len(skipped)} step(s) not run")
|
|
519
|
+
finish(failed=bool(skipped))
|
|
520
|
+
return True
|
|
521
|
+
|
|
522
|
+
window.connect("delete-event", on_close)
|
|
408
523
|
view.connect("load-changed", on_load_changed)
|
|
409
524
|
view.connect("load-failed", on_load_failed)
|
|
410
525
|
view.load_uri(args.url)
|
|
@@ -414,6 +529,13 @@ def main():
|
|
|
414
529
|
|
|
415
530
|
sys.stdout.flush()
|
|
416
531
|
sys.stderr.flush()
|
|
532
|
+
try:
|
|
533
|
+
view.terminate_web_process()
|
|
534
|
+
window.destroy()
|
|
535
|
+
while Gtk.events_pending():
|
|
536
|
+
Gtk.main_iteration_do(False)
|
|
537
|
+
except Exception:
|
|
538
|
+
pass
|
|
417
539
|
os._exit(1 if state["failed"] else 0)
|
|
418
540
|
|
|
419
541
|
|
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}`
|
|
@@ -64,6 +66,7 @@ export interface PreviewConfig {
|
|
|
64
66
|
globals?: Record<string, unknown>;
|
|
65
67
|
locale?: string;
|
|
66
68
|
macros?: Record<string, Step[] | string[]>;
|
|
69
|
+
annotate?: { private?: string[]; keepOpen?: boolean }
|
|
67
70
|
failOnError?: boolean;
|
|
68
71
|
}
|
|
69
72
|
|
|
@@ -89,6 +92,8 @@ export interface MockResponse {
|
|
|
89
92
|
export type InitEntry = string | { file: string }
|
|
90
93
|
|
|
91
94
|
export interface PreviewOptions extends PreviewConfig {
|
|
95
|
+
private?: string | string[];
|
|
96
|
+
keepOpen?: boolean;
|
|
92
97
|
target?: string;
|
|
93
98
|
steps?: Step[] | string[];
|
|
94
99
|
initFile?: string | string[];
|
|
@@ -97,11 +102,57 @@ export interface PreviewOptions extends PreviewConfig {
|
|
|
97
102
|
log?: { info: (message: string) => void; error: (message: string) => void }
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
export interface AnnotationSource {
|
|
106
|
+
file: string;
|
|
107
|
+
line: number | null;
|
|
108
|
+
column: number | null;
|
|
109
|
+
how: "debug-source" | "debug-stack" | "vue-file";
|
|
110
|
+
confidence?: "exact" | "nearest" | "file";
|
|
111
|
+
isMapped?: false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface AnnotationElement {
|
|
115
|
+
selector: string;
|
|
116
|
+
selectorQuality: "stable" | "positional";
|
|
117
|
+
tag: string;
|
|
118
|
+
role: string | null;
|
|
119
|
+
name: string | null;
|
|
120
|
+
text: string | null;
|
|
121
|
+
label: string;
|
|
122
|
+
component: {
|
|
123
|
+
name: string | null;
|
|
124
|
+
framework: "react" | "vue";
|
|
125
|
+
chain: { name: string; usedAt: AnnotationSource | null }[];
|
|
126
|
+
source: AnnotationSource | null;
|
|
127
|
+
} | null;
|
|
128
|
+
box: { margin: string; padding: string; border: string; borderRadius: string }
|
|
129
|
+
style: { display: string; font: string; color: string; background: string; opacity: string; gap: string }
|
|
130
|
+
rect: { x: number; y: number; width: number; height: number }
|
|
131
|
+
url: string;
|
|
132
|
+
isLive: boolean;
|
|
133
|
+
isPrivate: boolean;
|
|
134
|
+
image: string | null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface Annotation {
|
|
138
|
+
version: 1;
|
|
139
|
+
name: string;
|
|
140
|
+
status: "done" | "sent" | "cancelled";
|
|
141
|
+
round: number;
|
|
142
|
+
page: { url: string; route: string; engine: Exclude<Engine, "auto">; size: { width: number; height: number }; scale: number; takenAt: string }
|
|
143
|
+
image: string | null;
|
|
144
|
+
notes: { id: number; text: string; elements: string[]; url: string; isFrozen: boolean }[];
|
|
145
|
+
maskedCount: number;
|
|
146
|
+
generalNote: string | null;
|
|
147
|
+
elements: Record<string, AnnotationElement>;
|
|
148
|
+
}
|
|
149
|
+
|
|
100
150
|
export interface PreviewResult {
|
|
101
151
|
engine: Exclude<Engine, "auto">;
|
|
102
152
|
url: string;
|
|
103
153
|
sizes: string[];
|
|
104
154
|
saved: string[];
|
|
155
|
+
annotations: (Annotation | { name: string; status: "cancelled"; result: null })[];
|
|
105
156
|
consoleErrors: number;
|
|
106
157
|
failedAsserts: number;
|
|
107
158
|
report?: string;
|
package/src/index.mjs
CHANGED
|
@@ -12,6 +12,10 @@ 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
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,34 @@ 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 keepOpen = !!(options.keepOpen ?? config.annotate?.keepOpen);
|
|
78
|
+
const REPLAYED = new Set(["js", "jsnow", "wait", "waitfor", "key", "type", "hover", "click", "offline", "online", "latency"]);
|
|
79
|
+
const steps = parsedSteps.map((step, index) => step.kind === "annotate"
|
|
80
|
+
? {
|
|
81
|
+
...annotateStep(step.value, savedKeys, privateSelectors, keepOpen),
|
|
82
|
+
replay: parsedSteps.slice(0, index).filter(earlier => REPLAYED.has(earlier.kind) && !earlier.assert).map(earlier => ({ ...earlier, isReplay: true }))
|
|
83
|
+
}
|
|
84
|
+
: step);
|
|
60
85
|
const out = path.resolve(baseDir, merged.out);
|
|
61
86
|
const url = resolveUrl(merged.target ?? merged.url ?? "/", merged.baseUrl);
|
|
62
87
|
const sizes = parseSizes(merged.size);
|
|
@@ -112,9 +137,35 @@ export const preview = async (options = {}) => {
|
|
|
112
137
|
const stopServer = await startServer(server);
|
|
113
138
|
try {
|
|
114
139
|
const prefix = merged.prefix ?? `${engine}-`;
|
|
115
|
-
const result = { saved: [], failed: false }
|
|
140
|
+
const result = { saved: [], failed: false, annotations: [] }
|
|
141
|
+
const hasAnnotate = steps.some(step => step.kind === "annotate");
|
|
116
142
|
for (const size of sizes) {
|
|
117
143
|
if (sizes.length > 1) output.log.info(`size ${size}`);
|
|
144
|
+
const reports = [];
|
|
145
|
+
const handleAnnotation = async annotation => {
|
|
146
|
+
try {
|
|
147
|
+
await resolveSources(annotation, baseDir);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
output.log.error(`annotate: source files not resolved — ${error.message}`);
|
|
150
|
+
}
|
|
151
|
+
if (annotation.keys) {
|
|
152
|
+
try {
|
|
153
|
+
output.log.info(`annotate keys saved → ${saveAnnotateKeys(annotation.keys)}`);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
output.log.error(`annotate: keys not saved — ${error.message}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
delete annotation.keys;
|
|
159
|
+
for (const line of formatAnnotation(annotation)) output.log.info(line);
|
|
160
|
+
if (annotation.status !== "done" && annotation.status !== "sent") {
|
|
161
|
+
result.annotations.push(annotation);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const files = writeAnnotationReport({ annotation, out, prefix: sizes.length > 1 ? `${prefix}${size}-` : prefix, engine, url, baseUrl: merged.baseUrl, size: parseSize(size) });
|
|
165
|
+
output.log.info(` → ${files.json}`);
|
|
166
|
+
output.log.info(` → ${files.markdown}`);
|
|
167
|
+
result.annotations.push(files.report);
|
|
168
|
+
}
|
|
118
169
|
const run = await runEngine(engine, {
|
|
119
170
|
url,
|
|
120
171
|
steps: [...steps, consoleStep],
|
|
@@ -127,15 +178,17 @@ export const preview = async (options = {}) => {
|
|
|
127
178
|
prefix: sizes.length > 1 ? `${prefix}${size}-` : prefix,
|
|
128
179
|
scale: merged.scale === undefined ? undefined : Number(merged.scale),
|
|
129
180
|
snapMargin,
|
|
130
|
-
isHeaded: !!merged.headed,
|
|
181
|
+
isHeaded: merged.headed === undefined ? hasAnnotate : !!merged.headed,
|
|
131
182
|
isStorageKept: !!merged.keepStorage,
|
|
132
183
|
waitForTimeoutMs: Number(merged.waitTimeout ?? 10000),
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
:
|
|
184
|
+
onAnnotation: annotation => reports.push(handleAnnotation(annotation)),
|
|
185
|
+
timeoutMs: merged.timeout !== undefined ? Number(merged.timeout)
|
|
186
|
+
: hasAnnotate ? 0
|
|
187
|
+
: runTimeoutMs([...steps, consoleStep], Number(merged.waitTimeout ?? 10000)),
|
|
136
188
|
log: output.log
|
|
137
189
|
});
|
|
138
190
|
result.saved.push(...run.saved);
|
|
191
|
+
await Promise.all(reports);
|
|
139
192
|
for (const film of run.films ?? []) {
|
|
140
193
|
try {
|
|
141
194
|
output.log.info(stitchFilm(film, snapMargin));
|
|
@@ -166,7 +219,7 @@ export const preview = async (options = {}) => {
|
|
|
166
219
|
if (report) baseLog.info(`report → ${report}`);
|
|
167
220
|
return { engine, url, sizes, ...result, consoleErrors, failedAsserts, report, failed }
|
|
168
221
|
} finally {
|
|
169
|
-
stopServer();
|
|
222
|
+
await stopServer();
|
|
170
223
|
}
|
|
171
224
|
}
|
|
172
225
|
|
|
@@ -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,22 @@
|
|
|
1
|
+
export const formatAnnotation = annotation => {
|
|
2
|
+
const lines = [];
|
|
3
|
+
const { name, status, result } = annotation;
|
|
4
|
+
if ((status !== "done" && status !== "sent") || !result) return [`annotate ${name}: cancelled`];
|
|
5
|
+
const elementCount = Object.keys(result.elements).length;
|
|
6
|
+
const round = annotation.round > 1 || status === "sent" ? ` (round ${annotation.round ?? 1})` : "";
|
|
7
|
+
lines.push(`annotate ${name}: ${status}${round} · ${result.notes.length} note(s) · ${elementCount} element(s)${result.generalNote ? " · 1 general note" : ""}${result.maskedCount ? ` · ${result.maskedCount} private element(s) hidden` : ""}`);
|
|
8
|
+
for (const note of result.notes) {
|
|
9
|
+
const targets = note.elements.map(id => {
|
|
10
|
+
const element = result.elements[id];
|
|
11
|
+
if (!element) return id;
|
|
12
|
+
const source = element.component?.source;
|
|
13
|
+
const where = source ? ` (${source.file}${source.line ? `:${source.line}` : ""})` : "";
|
|
14
|
+
return element.component?.name ? `${element.component.name} ${element.label}${where}` : `${element.label}${where}`;
|
|
15
|
+
});
|
|
16
|
+
const where = targets.length === 1 ? targets[0] : `${targets.length} elements: ${targets.join(", ")}`;
|
|
17
|
+
lines.push(` ${note.id}. ${where} — ${note.text || "(no text)"}`);
|
|
18
|
+
for (const id of note.elements) lines.push(` ${id}: ${result.elements[id]?.selector}`);
|
|
19
|
+
}
|
|
20
|
+
if (result.generalNote) lines.push(` general: ${result.generalNote}`);
|
|
21
|
+
return lines;
|
|
22
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
send: icon("<path d='M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z'/><path d='m21.854 2.147-10.94 10.939'/>"),
|
|
11
|
+
done: icon("<path d='M20 6 9 17l-5-5'/>"),
|
|
12
|
+
cancel: icon("<path d='M18 6 6 18'/><path d='m6 6 12 12'/>")
|
|
13
|
+
}
|