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.
@@ -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: true });
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
  });
@@ -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
- deliver(event)
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
- expected = count(text) + sum(1 + len(modifiers) for _, modifiers in presses)
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
- started = GLib.get_monotonic_time()
178
+ progress = {"keyups": start, "since": GLib.get_monotonic_time()}
174
179
 
175
180
  def check():
176
181
  def on_value(text):
177
- if count(text) >= expected or (GLib.get_monotonic_time() - started) / 1000 >= KEY_WAIT_MS:
182
+ keyups = count(text)
183
+ if keyups >= expected:
178
184
  GLib.timeout_add(INPUT_SETTLE_MS, run_step)
179
- else:
180
- GLib.timeout_add(KEY_POLL_MS, lambda: (check(), False)[1])
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
- emit("saved", path)
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 snap_element(step, rect):
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
- step_failed(f"snap:{step['value']} failed: element is outside the window")
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(step["value"], cropped)
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
- GLib.timeout_add(args.timeout_ms, on_timeout)
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 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 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
+ });