bee-ladybug 0.5.3 → 0.6.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.
@@ -4,6 +4,7 @@
4
4
  * Reads generic AABB entities, measures host FPS, computes collisions,
5
5
  * and pushes packets into the core. Hitboxes are drawn on a stacked
6
6
  * overlay canvas so the game pixels stay untouched.
7
+ * `snapshot()` hands the report hitboxes, collisions, and FPS min/avg/max.
7
8
  *
8
9
  * The core must never import this file.
9
10
  *
@@ -59,6 +60,7 @@ export class CanvasAdapter {
59
60
  this.timeScale = 1;
60
61
 
61
62
  this.#attached = false;
63
+ this.#reporting = true;
62
64
  this.#overlay = null;
63
65
  this.#unsubscribe = null;
64
66
  this.#rafId = 0;
@@ -66,6 +68,7 @@ export class CanvasAdapter {
66
68
  this.#manualPumpWarned = false;
67
69
  this.#lastPump = 0;
68
70
  this.#fps = 0;
71
+ this.#fpsSamples = [];
69
72
  this.#wasColliding = new Set();
70
73
  this.#onResize = () => this.#syncOverlay();
71
74
  this.#onTick = () => {
@@ -80,6 +83,7 @@ export class CanvasAdapter {
80
83
  }
81
84
 
82
85
  #attached;
86
+ #reporting;
83
87
  #overlay;
84
88
  #unsubscribe;
85
89
  #rafId;
@@ -87,6 +91,7 @@ export class CanvasAdapter {
87
91
  #manualPumpWarned;
88
92
  #lastPump;
89
93
  #fps;
94
+ #fpsSamples;
90
95
  #wasColliding;
91
96
  #onResize;
92
97
  #onTick;
@@ -108,6 +113,15 @@ export class CanvasAdapter {
108
113
  }
109
114
  if (this.autoPump) this.startAutoPump();
110
115
 
116
+ this.#reporting = true;
117
+ this.core?.registerAdapter('canvas', {
118
+ label: 'CANVAS',
119
+ enabled: true,
120
+ enable: () => this.#enable(),
121
+ disable: () => this.#disable(),
122
+ snapshot: () => this.snapshot()
123
+ });
124
+
111
125
  this.core?.sendData('log', {
112
126
  source: this.source,
113
127
  message: `CanvasAdapter attached (${this.entities.length} entities).`
@@ -126,6 +140,7 @@ export class CanvasAdapter {
126
140
  this.#stopAutoPumpLoop();
127
141
  if (this.#unsubscribe) this.#unsubscribe();
128
142
  this.#unsubscribe = null;
143
+ this.core?.unregisterAdapter('canvas');
129
144
  this.#unmountOverlay();
130
145
  if (typeof window !== 'undefined') {
131
146
  window.removeEventListener('resize', this.#onResize);
@@ -161,6 +176,28 @@ export class CanvasAdapter {
161
176
  return this;
162
177
  }
163
178
 
179
+ /**
180
+ * Hitboxes currently on the entity list, plus FPS min/avg/max.
181
+ * Returns null when the adapter is not attached. The core stores this
182
+ * function and does not read the rows.
183
+ * @returns {{ name: string, items: Array<{ severity: string, selector: string, rule: string, value: string, source: string }> } | null}
184
+ */
185
+ snapshot() {
186
+ if (!this.#attached) return null;
187
+ const boxes = this.#collectBoxes();
188
+ this.#markCollisions(boxes);
189
+ const items = boxes.map((box) => ({
190
+ severity: box.hit ? 'warn' : 'ok',
191
+ selector: box.name,
192
+ rule: 'hitbox',
193
+ value: `${box.x},${box.y} ${box.w}x${box.h}${box.hit ? ' colliding' : ''}`,
194
+ source: this.source
195
+ }));
196
+ const fps = this.#fpsItem();
197
+ if (fps) items.push(fps);
198
+ return { name: 'canvas', items };
199
+ }
200
+
164
201
  /**
165
202
  * Call once per host frame, after entity positions are updated.
166
203
  * Pass a 2D context only if overlay is disabled and you want hitboxes
@@ -174,6 +211,7 @@ export class CanvasAdapter {
174
211
  this.#warnManualPump();
175
212
  return this;
176
213
  }
214
+ if (!this.#reporting) return this;
177
215
 
178
216
  const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
179
217
  if (this.#lastPump) {
@@ -257,6 +295,8 @@ export class CanvasAdapter {
257
295
  if (!tracked && boxes[0]) tracked = boxes[0];
258
296
 
259
297
  if (this.#fps > 0) {
298
+ this.#fpsSamples.push(this.#fps);
299
+ if (this.#fpsSamples.length > 120) this.#fpsSamples.shift();
260
300
  this.core.sendData('fps', { value: this.#fps, source: this.source });
261
301
  }
262
302
  this.core.sendData('metric', {
@@ -374,7 +414,49 @@ export class CanvasAdapter {
374
414
  overlay.style.top = `${rect.top}px`;
375
415
  overlay.style.width = `${rect.width}px`;
376
416
  overlay.style.height = `${rect.height}px`;
377
- overlay.style.display = this.core && this.core.visible === false ? 'none' : 'block';
417
+ const hidden = !this.#reporting || (this.core && this.core.visible === false);
418
+ overlay.style.display = hidden ? 'none' : 'block';
419
+ }
420
+
421
+ #enable() {
422
+ this.#reporting = true;
423
+ this.#syncOverlay();
424
+ }
425
+
426
+ #disable() {
427
+ this.#reporting = false;
428
+ this.#syncOverlay();
429
+ }
430
+
431
+ #fpsItem() {
432
+ const stats = this.#fpsStats();
433
+ if (!stats) return null;
434
+ return {
435
+ severity: 'ok',
436
+ selector: 'host',
437
+ rule: 'fps',
438
+ value: `min ${round1(stats.min)} avg ${round1(stats.avg)} max ${round1(stats.max)}`,
439
+ source: this.source
440
+ };
441
+ }
442
+
443
+ #fpsStats() {
444
+ const history = typeof this.core?.getHistory === 'function' ? this.core.getHistory('fps') : null;
445
+ if (history && Array.isArray(history.values) && history.values.length) {
446
+ return { min: history.min, avg: history.avg, max: history.max };
447
+ }
448
+ const samples = this.#fpsSamples;
449
+ if (!samples.length) return null;
450
+ let min = samples[0];
451
+ let max = samples[0];
452
+ let sum = 0;
453
+ for (let i = 0; i < samples.length; i++) {
454
+ const value = samples[i];
455
+ if (value < min) min = value;
456
+ if (value > max) max = value;
457
+ sum += value;
458
+ }
459
+ return { min, avg: sum / samples.length, max };
378
460
  }
379
461
  }
380
462
 
@@ -409,3 +491,7 @@ function idFromKey(id) {
409
491
  const parts = String(id).split(':');
410
492
  return parts[parts.length - 1] || id;
411
493
  }
494
+
495
+ function round1(value) {
496
+ return Math.round(Number(value) * 10) / 10;
497
+ }
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * SpiderAdapter — long-task and slow-resource tracker.
3
3
  *
4
- * Native PerformanceObserver only. The core must never import this file.
4
+ * Native PerformanceObserver only. `snapshot()` keeps the long tasks and
5
+ * slow resources already seen. The core must never import this file.
5
6
  *
6
7
  * @example
7
8
  * const spider = new SpiderAdapter(core, { resourceThreshold: 500 });
8
9
  * spider.attach();
9
10
  */
10
11
 
12
+ const MAX_SPIDER = 40;
13
+
11
14
  export const SPIDER_ADAPTER_DEFAULTS = Object.freeze({
12
15
  longTaskThreshold: 50,
13
16
  resourceThreshold: 500,
@@ -30,6 +33,8 @@ export class SpiderAdapter {
30
33
  this.#longObserver = null;
31
34
  this.#resourceObserver = null;
32
35
  this.#longTaskMissingLogged = false;
36
+ this.#longTasks = [];
37
+ this.#resources = [];
33
38
  this.#onLongTask = (list) => this.#ingestLongTasks(list);
34
39
  this.#onResource = (list) => this.#ingestResources(list);
35
40
  }
@@ -38,6 +43,8 @@ export class SpiderAdapter {
38
43
  #longObserver;
39
44
  #resourceObserver;
40
45
  #longTaskMissingLogged;
46
+ #longTasks;
47
+ #resources;
41
48
  #onLongTask;
42
49
  #onResource;
43
50
 
@@ -48,7 +55,8 @@ export class SpiderAdapter {
48
55
  this.core?.registerAdapter('spider', {
49
56
  enable: () => this.#enable(),
50
57
  disable: () => this.#disable(),
51
- label: 'SPIDER'
58
+ label: 'SPIDER',
59
+ snapshot: () => this.snapshot()
52
60
  });
53
61
  this.core?.sendData('log', {
54
62
  source: this.source,
@@ -65,6 +73,36 @@ export class SpiderAdapter {
65
73
  return this;
66
74
  }
67
75
 
76
+ /**
77
+ * Long tasks and slow resources kept since attach. Returns null when detached.
78
+ * @returns {{ name: string, items: Array<{ severity: string, selector: string, rule: string, value: string, source: string }> } | null}
79
+ */
80
+ snapshot() {
81
+ if (!this.#attached) return null;
82
+ const items = [];
83
+ for (let i = 0; i < this.#longTasks.length; i++) {
84
+ const entry = this.#longTasks[i];
85
+ items.push({
86
+ severity: 'warn',
87
+ selector: entry.attribution,
88
+ rule: 'longtask',
89
+ value: `${entry.duration}ms`,
90
+ source: this.source
91
+ });
92
+ }
93
+ for (let i = 0; i < this.#resources.length; i++) {
94
+ const entry = this.#resources[i];
95
+ items.push({
96
+ severity: 'warn',
97
+ selector: entry.name,
98
+ rule: 'resource',
99
+ value: `${entry.duration}ms`,
100
+ source: this.source
101
+ });
102
+ }
103
+ return { name: 'spider', items };
104
+ }
105
+
68
106
  #enable() {
69
107
  if (!this.#attached) return;
70
108
  if (typeof PerformanceObserver !== 'function') {
@@ -111,6 +149,10 @@ export class SpiderAdapter {
111
149
  for (let i = 0; i < entries.length; i++) {
112
150
  const entry = entries[i];
113
151
  if (!entry || entry.duration <= this.longTaskThreshold) continue;
152
+ remember(this.#longTasks, {
153
+ attribution: entry.attribution?.[0]?.containerType ?? 'unknown',
154
+ duration: Math.round(entry.duration)
155
+ });
114
156
  this.core.sendData('telemetry', {
115
157
  source: this.source,
116
158
  kind: 'longtask',
@@ -126,6 +168,10 @@ export class SpiderAdapter {
126
168
  for (let i = 0; i < entries.length; i++) {
127
169
  const entry = entries[i];
128
170
  if (!entry || entry.duration <= this.resourceThreshold) continue;
171
+ remember(this.#resources, {
172
+ name: String(entry.name || 'resource'),
173
+ duration: Math.round(entry.duration)
174
+ });
129
175
  this.core.sendData('warn', {
130
176
  source: this.source,
131
177
  message: `Risorsa lenta: ${entry.name} (${Math.round(entry.duration)}ms)`
@@ -143,6 +189,11 @@ export class SpiderAdapter {
143
189
  }
144
190
  }
145
191
 
192
+ function remember(list, entry) {
193
+ list.unshift(entry);
194
+ if (list.length > MAX_SPIDER) list.pop();
195
+ }
196
+
146
197
  function positive(value, fallback) {
147
198
  const n = Number(value);
148
199
  return Number.isFinite(n) && n > 0 ? n : fallback;
@@ -4,6 +4,8 @@
4
4
  * Reads box models, the node under the pointer, and mutation noise,
5
5
  * then pushes packets into the core. Highlights are drawn on a stacked
6
6
  * overlay canvas so the page stays untouched.
7
+ * `snapshot()` hands the report the box model, problem nodes, and
8
+ * the latest mutation counts.
7
9
  *
8
10
  * The core must never import this file.
9
11
  *
@@ -12,6 +14,10 @@
12
14
  * adapter.attach();
13
15
  */
14
16
 
17
+ import { isReportNoise } from '../core/ReportExporter.js';
18
+
19
+ const MAX_PROBLEM_ROWS = 40;
20
+
15
21
  export const WEB_DOM_ADAPTER_DEFAULTS = Object.freeze({
16
22
  overlay: true,
17
23
  autoTick: true,
@@ -52,6 +58,7 @@ export class WebDOMAdapter {
52
58
  this.hover = null;
53
59
 
54
60
  this.#attached = false;
61
+ this.#reporting = true;
55
62
  this.#overlay = null;
56
63
  this.#unsubscribe = null;
57
64
  this.#observer = null;
@@ -62,6 +69,9 @@ export class WebDOMAdapter {
62
69
  this.#mutAdded = 0;
63
70
  this.#mutRemoved = 0;
64
71
  this.#mutAttr = 0;
72
+ this.#seenAdded = 0;
73
+ this.#seenRemoved = 0;
74
+ this.#seenAttr = 0;
65
75
  this.#mutTimer = 0;
66
76
  this.#lastHoverKey = '';
67
77
  this.#onMove = (event) => {
@@ -78,6 +88,7 @@ export class WebDOMAdapter {
78
88
  }
79
89
 
80
90
  #attached;
91
+ #reporting;
81
92
  #overlay;
82
93
  #unsubscribe;
83
94
  #observer;
@@ -88,6 +99,9 @@ export class WebDOMAdapter {
88
99
  #mutAdded;
89
100
  #mutRemoved;
90
101
  #mutAttr;
102
+ #seenAdded;
103
+ #seenRemoved;
104
+ #seenAttr;
91
105
  #mutTimer;
92
106
  #lastHoverKey;
93
107
  #onMove;
@@ -123,6 +137,15 @@ export class WebDOMAdapter {
123
137
  this.#rafId = requestAnimationFrame(this.#onTick);
124
138
  }
125
139
 
140
+ this.#reporting = true;
141
+ this.core?.registerAdapter('dom', {
142
+ label: 'DOM',
143
+ enabled: true,
144
+ enable: () => this.#enable(),
145
+ disable: () => this.#disable(),
146
+ snapshot: () => this.snapshot()
147
+ });
148
+
126
149
  this.core?.sendData('log', {
127
150
  source: this.source,
128
151
  message: 'WebDOMAdapter attached. Hover a node to inspect the box model.'
@@ -148,6 +171,7 @@ export class WebDOMAdapter {
148
171
  this.#rafId = 0;
149
172
  if (this.#mutTimer && typeof clearTimeout === 'function') clearTimeout(this.#mutTimer);
150
173
  this.#mutTimer = 0;
174
+ this.core?.unregisterAdapter('dom');
151
175
  if (typeof window !== 'undefined') {
152
176
  window.removeEventListener('mousemove', this.#onMove, true);
153
177
  window.removeEventListener('resize', this.#onResize);
@@ -172,8 +196,36 @@ export class WebDOMAdapter {
172
196
  return this;
173
197
  }
174
198
 
199
+ /**
200
+ * Box model of the hover and watched nodes, layout problems under root,
201
+ * and the latest mutation counts. Returns null when detached.
202
+ * Hidden boxes, the WordPress admin bar, and BeeLadybug chrome are skipped.
203
+ * The core stores this function and does not read the rows.
204
+ * @returns {{ name: string, items: Array<{ severity: string, selector: string, rule: string, value: string, source: string }> } | null}
205
+ */
206
+ snapshot() {
207
+ if (!this.#attached) return null;
208
+ const items = [];
209
+ const seen = new Set();
210
+ const targets = [];
211
+ if (this.hover) targets.push(this.hover);
212
+ const watched = this.#watchedNodes();
213
+ for (let i = 0; i < watched.length; i++) targets.push(watched[i]);
214
+ for (let i = 0; i < targets.length; i++) {
215
+ const node = targets[i];
216
+ if (!node || seen.has(node) || !reportable(node)) continue;
217
+ seen.add(node);
218
+ items.push(boxItem(node, this.source));
219
+ }
220
+ const problems = problemItems(this.root, this.source);
221
+ for (let i = 0; i < problems.length; i++) items.push(problems[i]);
222
+ items.push(mutationItem(this.#mutationCounts(), this.root, this.source));
223
+ return { name: 'dom', items };
224
+ }
225
+
175
226
  pump() {
176
227
  if (!this.#attached || !this.core) return this;
228
+ if (!this.#reporting) return this;
177
229
 
178
230
  const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
179
231
  if (this.#lastPump) {
@@ -311,6 +363,11 @@ export class WebDOMAdapter {
311
363
  const added = this.#mutAdded;
312
364
  const removed = this.#mutRemoved;
313
365
  const attr = this.#mutAttr;
366
+ if (added || removed || attr) {
367
+ this.#seenAdded = added;
368
+ this.#seenRemoved = removed;
369
+ this.#seenAttr = attr;
370
+ }
314
371
  this.#mutAdded = 0;
315
372
  this.#mutRemoved = 0;
316
373
  this.#mutAttr = 0;
@@ -394,7 +451,26 @@ export class WebDOMAdapter {
394
451
  canvas.style.height = `${h}px`;
395
452
  canvas.width = Math.round(w * dpr);
396
453
  canvas.height = Math.round(h * dpr);
397
- canvas.style.display = this.core && this.core.visible === false ? 'none' : 'block';
454
+ const hidden = !this.#reporting || (this.core && this.core.visible === false);
455
+ canvas.style.display = hidden ? 'none' : 'block';
456
+ }
457
+
458
+ #enable() {
459
+ this.#reporting = true;
460
+ this.#syncOverlay();
461
+ }
462
+
463
+ #disable() {
464
+ this.#reporting = false;
465
+ this.#syncOverlay();
466
+ }
467
+
468
+ #mutationCounts() {
469
+ const pending = this.#mutAdded + this.#mutRemoved + this.#mutAttr;
470
+ if (pending) {
471
+ return { added: this.#mutAdded, removed: this.#mutRemoved, attr: this.#mutAttr };
472
+ }
473
+ return { added: this.#seenAdded, removed: this.#seenRemoved, attr: this.#seenAttr };
398
474
  }
399
475
  }
400
476
 
@@ -504,6 +580,138 @@ function drawLabel(ctx, el, viewW, viewH, color) {
504
580
  ctx.fillText(text, x + 6, y + height / 2);
505
581
  }
506
582
 
583
+ function reportable(node) {
584
+ if (!node || node.nodeType !== 1) return false;
585
+ if (typeof node.getBoundingClientRect !== 'function') return false;
586
+ if (isBeeChrome(node) || isReportNoise(node)) return false;
587
+ return true;
588
+ }
589
+
590
+ function boxItem(node, source) {
591
+ const model = boxModel(node);
592
+ return {
593
+ severity: 'ok',
594
+ selector: cssPath(node),
595
+ rule: 'box',
596
+ value: boxValue(model, styleOf(node)),
597
+ source
598
+ };
599
+ }
600
+
601
+ function boxValue(model, style) {
602
+ const border = model.border;
603
+ const content = model.content;
604
+ return `border ${Math.round(border.w)}x${Math.round(border.h)} margin ${sideText(style, 'margin')} padding ${sideText(style, 'padding')} content ${Math.round(content.w)}x${Math.round(content.h)} ${model.display}`;
605
+ }
606
+
607
+ function sideText(style, kind) {
608
+ const prefix = kind === 'margin' ? 'margin' : 'padding';
609
+ const top = style ? parsePx(style[`${prefix}Top`]) : 0;
610
+ const right = style ? parsePx(style[`${prefix}Right`]) : 0;
611
+ const bottom = style ? parsePx(style[`${prefix}Bottom`]) : 0;
612
+ const left = style ? parsePx(style[`${prefix}Left`]) : 0;
613
+ return `${top},${right},${bottom},${left}`;
614
+ }
615
+
616
+ function problemItems(root, source) {
617
+ const nodes = collectElements(root);
618
+ const items = [];
619
+ const seen = new Set();
620
+ for (let i = 0; i < nodes.length && items.length < MAX_PROBLEM_ROWS; i++) {
621
+ const node = nodes[i];
622
+ if (!node || seen.has(node) || !reportable(node)) continue;
623
+ seen.add(node);
624
+ const style = styleOf(node);
625
+ const margin = sideText(style, 'margin');
626
+ if (hasNegative(margin)) {
627
+ items.push(problemItem(node, 'negative margin', margin, source));
628
+ if (items.length >= MAX_PROBLEM_ROWS) break;
629
+ }
630
+ const clip = clipValue(node);
631
+ if (clip) {
632
+ items.push(problemItem(node, 'clipped', clip, source));
633
+ if (items.length >= MAX_PROBLEM_ROWS) break;
634
+ }
635
+ const model = boxModel(node);
636
+ if (model.border.w > 0 && model.border.h > 0 && (model.content.w === 0 || model.content.h === 0)) {
637
+ items.push(problemItem(
638
+ node,
639
+ 'collapsed content',
640
+ `content ${Math.round(model.content.w)}x${Math.round(model.content.h)}`,
641
+ source
642
+ ));
643
+ }
644
+ }
645
+ return items;
646
+ }
647
+
648
+ function problemItem(node, rule, value, source) {
649
+ return {
650
+ severity: 'warn',
651
+ selector: cssPath(node),
652
+ rule,
653
+ value,
654
+ source
655
+ };
656
+ }
657
+
658
+ function hasNegative(sides) {
659
+ return sides.split(',').some((part) => Number(part) < 0);
660
+ }
661
+
662
+ function clipValue(node) {
663
+ const sw = finite(node.scrollWidth);
664
+ const cw = finite(node.clientWidth);
665
+ const sh = finite(node.scrollHeight);
666
+ const ch = finite(node.clientHeight);
667
+ const wide = sw != null && cw != null && sw > cw + 2;
668
+ const tall = sh != null && ch != null && sh > ch + 2;
669
+ if (!wide && !tall) return '';
670
+ return `scroll ${sw ?? 0}x${sh ?? 0} client ${cw ?? 0}x${ch ?? 0}`;
671
+ }
672
+
673
+ function mutationItem(counts, root, source) {
674
+ const total = counts.added + counts.removed + counts.attr;
675
+ return {
676
+ severity: total > 0 ? 'warn' : 'ok',
677
+ selector: root && root.nodeType === 1 ? cssPath(root) : 'root',
678
+ rule: 'mutations',
679
+ value: `+${counts.added} -${counts.removed} attr ${counts.attr}`,
680
+ source
681
+ };
682
+ }
683
+
684
+ function styleOf(node) {
685
+ if (typeof getComputedStyle !== 'function') return null;
686
+ try {
687
+ return getComputedStyle(node);
688
+ } catch {
689
+ return null;
690
+ }
691
+ }
692
+
693
+ function collectElements(root) {
694
+ if (!root) return [];
695
+ const out = [];
696
+ if (root.nodeType === 1) out.push(root);
697
+ let list = null;
698
+ if (typeof root.querySelectorAll === 'function') {
699
+ try {
700
+ list = root.querySelectorAll('*');
701
+ } catch {
702
+ list = null;
703
+ }
704
+ }
705
+ if (list) {
706
+ for (let i = 0; i < list.length; i++) out.push(list[i]);
707
+ }
708
+ return out;
709
+ }
710
+
711
+ function finite(value) {
712
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
713
+ }
714
+
507
715
  function countElements(root) {
508
716
  if (!root) return 0;
509
717
  if (root.nodeType === 1) return 1 + (root.getElementsByTagName?.('*').length ?? 0);