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.
@@ -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: true });
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
  });
@@ -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
- emit("saved", path)
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 snap_element(step, rect):
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
- step_failed(f"snap:{step['value']} failed: element is outside the window")
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(step["value"], cropped)
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 steps = parseSteps(expandMacros(merged.steps.length ? merged.steps : DEFAULTS.steps, macros));
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
- timeoutMs: merged.timeout === undefined
134
- ? runTimeoutMs([...steps, consoleStep], Number(merged.waitTimeout ?? 10000))
135
- : Number(merged.timeout),
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
+ }