@sailfish-ai/recorder 1.12.6 → 1.12.8

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.
Files changed (64) hide show
  1. package/dist/chunks/{canvasManager-CoX5072G.js → canvasManager-3AzRUaNq.js} +1 -1
  2. package/dist/chunks/canvasManager-3AzRUaNq.js.br +0 -0
  3. package/dist/chunks/canvasManager-3AzRUaNq.js.gz +0 -0
  4. package/dist/chunks/{canvasManager-BYn3_oyo.js → canvasManager-CDEhVz_J.js} +1 -1
  5. package/dist/chunks/canvasManager-CDEhVz_J.js.br +0 -0
  6. package/dist/chunks/canvasManager-CDEhVz_J.js.gz +0 -0
  7. package/dist/chunks/{chunkSerializer-CghPitF4.js → chunkSerializer-B3loCr3q.js} +1 -1
  8. package/dist/chunks/chunkSerializer-B3loCr3q.js.br +0 -0
  9. package/dist/chunks/chunkSerializer-B3loCr3q.js.gz +0 -0
  10. package/dist/chunks/{chunkSerializer-C_K0Wyk0.js → chunkSerializer-Cz0TPV-B.js} +1 -1
  11. package/dist/chunks/chunkSerializer-Cz0TPV-B.js.br +0 -0
  12. package/dist/chunks/chunkSerializer-Cz0TPV-B.js.gz +0 -0
  13. package/dist/chunks/hoverDetection-Bl-yKKI3.js +137 -0
  14. package/dist/chunks/hoverDetection-Bl-yKKI3.js.br +0 -0
  15. package/dist/chunks/hoverDetection-Bl-yKKI3.js.gz +0 -0
  16. package/dist/chunks/hoverDetection-CYsyBQjj.js +139 -0
  17. package/dist/chunks/hoverDetection-CYsyBQjj.js.br +0 -0
  18. package/dist/chunks/hoverDetection-CYsyBQjj.js.gz +0 -0
  19. package/dist/chunks/{index-DIYomvdD.js → index-BUAmLDnY.js} +109 -103
  20. package/dist/chunks/index-BUAmLDnY.js.br +0 -0
  21. package/dist/chunks/index-BUAmLDnY.js.gz +0 -0
  22. package/dist/chunks/{index-BIj4l3CV.js → index-CPZwt1ko.js} +59 -53
  23. package/dist/chunks/index-CPZwt1ko.js.br +0 -0
  24. package/dist/chunks/index-CPZwt1ko.js.gz +0 -0
  25. package/dist/hoverDetection.js +371 -0
  26. package/dist/hoverDetection.js.br +0 -0
  27. package/dist/hoverDetection.js.gz +0 -0
  28. package/dist/hoverDetection.test.js +187 -0
  29. package/dist/hoverDetection.test.js.br +0 -0
  30. package/dist/hoverDetection.test.js.gz +0 -0
  31. package/dist/inAppReportIssueModal/index.js +10 -1
  32. package/dist/inAppReportIssueModal/index.js.br +0 -0
  33. package/dist/inAppReportIssueModal/index.js.gz +0 -0
  34. package/dist/index.js +8 -0
  35. package/dist/index.js.br +0 -0
  36. package/dist/index.js.gz +0 -0
  37. package/dist/recorder.cjs +1 -1
  38. package/dist/recorder.cjs.br +0 -0
  39. package/dist/recorder.cjs.gz +0 -0
  40. package/dist/recorder.js +1 -1
  41. package/dist/recorder.js.br +0 -0
  42. package/dist/recorder.js.gz +0 -0
  43. package/dist/recorder.umd.cjs +572 -434
  44. package/dist/recorder.umd.cjs.br +0 -0
  45. package/dist/recorder.umd.cjs.gz +0 -0
  46. package/dist/recording.js +27 -0
  47. package/dist/recording.js.br +0 -0
  48. package/dist/recording.js.gz +0 -0
  49. package/dist/types/hoverDetection.d.ts +37 -0
  50. package/dist/types/hoverDetection.test.d.ts +1 -0
  51. package/dist/types/types.d.ts +6 -0
  52. package/package.json +1 -1
  53. package/dist/chunks/canvasManager-BYn3_oyo.js.br +0 -0
  54. package/dist/chunks/canvasManager-BYn3_oyo.js.gz +0 -0
  55. package/dist/chunks/canvasManager-CoX5072G.js.br +0 -0
  56. package/dist/chunks/canvasManager-CoX5072G.js.gz +0 -0
  57. package/dist/chunks/chunkSerializer-C_K0Wyk0.js.br +0 -0
  58. package/dist/chunks/chunkSerializer-C_K0Wyk0.js.gz +0 -0
  59. package/dist/chunks/chunkSerializer-CghPitF4.js.br +0 -0
  60. package/dist/chunks/chunkSerializer-CghPitF4.js.gz +0 -0
  61. package/dist/chunks/index-BIj4l3CV.js.br +0 -0
  62. package/dist/chunks/index-BIj4l3CV.js.gz +0 -0
  63. package/dist/chunks/index-DIYomvdD.js.br +0 -0
  64. package/dist/chunks/index-DIYomvdD.js.gz +0 -0
@@ -0,0 +1,371 @@
1
+ // Hover action detection (DIAGPLAT-1790) — v2.
2
+ //
3
+ // rrweb has no native hover/mouseover interaction, so the recorder detects it
4
+ // itself. v2 design:
5
+ // • Detect dwell over an element with hover-like affordance (tooltip, menu/
6
+ // popover trigger, cursor:pointer, or a matching `:hover` CSS rule).
7
+ // • At the dwell mark, RE-CHECK `el.matches(":hover")` — only then is it a
8
+ // real, intentional hover (kills the entered-then-left race).
9
+ // • Emit ONCE at hover-END, carrying `duration_ms` (real dwell time).
10
+ // • Suppress at the source: if the hover ended because the user CLICKED the
11
+ // element, don't emit (a hover→click is just a click). Because this is
12
+ // decided locally and in order, it needs no backend/Kafka-batch dedup.
13
+ // • Volume guard before anything is emitted: per-element cooldown +
14
+ // token-bucket rate limit + hard session cap — so a pathological page can
15
+ // never flood the WebSocket/Kafka/Postgres.
16
+ //
17
+ // SSR-safe: every entry point guards against a missing window/document.
18
+ import { EventType } from "@sailfish-rrweb/types";
19
+ // ARIA roles that imply hover-revealed UI (menus, popovers, tooltips).
20
+ const MENU_ROLES = new Set(["menu", "menuitem", "menubar", "tooltip"]);
21
+ // Memoized "base" selectors (rightmost compound, `:hover` stripped) from every
22
+ // readable stylesheet. Recomputed only when the stylesheet count changes.
23
+ let cachedSheetCount = -1;
24
+ let hoverBaseSelectors = [];
25
+ // From a single comma-separated selector that contains `:hover`, push the
26
+ // "base" selector of the element that is actually HOVERED. The `:hover` may
27
+ // qualify any compound in the chain, not the rightmost one: in
28
+ // `.menu:hover .item` the user hovers `.menu` (and `.item` is merely revealed),
29
+ // so the affordance element is `.menu`, NOT `.item`. We therefore split the
30
+ // selector into its compounds (by combinator) and, for each compound that
31
+ // carries `:hover`, strip the `:hover` (and any pseudo-element) to recover the
32
+ // structural selector to match against.
33
+ function collectHoverBases(selectorText, out) {
34
+ for (const part of selectorText.split(",")) {
35
+ if (part.indexOf(":hover") === -1)
36
+ continue;
37
+ // Split into compounds on combinators (descendant / > / + / ~).
38
+ for (const compound of part.split(/\s*[>+~]\s*|\s+/)) {
39
+ if (compound.indexOf(":hover") === -1)
40
+ continue;
41
+ const base = compound
42
+ .replace(/:hover\b/g, "")
43
+ // pseudo-elements (::before/::after) el.matches() can't evaluate —
44
+ // drop them so e.g. `.tip:hover::after` still resolves to `.tip`.
45
+ .replace(/::[a-zA-Z-]+/g, "")
46
+ .trim();
47
+ if (base)
48
+ out.push(base);
49
+ }
50
+ }
51
+ }
52
+ // Walk a rule list, descending into grouping rules (@media / @supports /
53
+ // @layer / @container) and CSS-nested rules — a `:hover` rule nested inside
54
+ // `@media (hover: hover) { … }` (the modern best-practice pattern) would
55
+ // otherwise be invisible, so strict affordance would miss it entirely.
56
+ function collectFromRules(rules, out, depth) {
57
+ if (depth > 10)
58
+ return; // pathological nesting guard
59
+ for (let j = 0; j < rules.length; j++) {
60
+ const rule = rules[j];
61
+ const selectorText = rule.selectorText;
62
+ if (selectorText && selectorText.indexOf(":hover") !== -1) {
63
+ collectHoverBases(selectorText, out);
64
+ }
65
+ // CSSGroupingRule (and CSS-nested CSSStyleRule) expose `.cssRules`.
66
+ let nested = null;
67
+ try {
68
+ nested = rule.cssRules ?? null;
69
+ }
70
+ catch {
71
+ nested = null; // inaccessible
72
+ }
73
+ if (nested && nested.length)
74
+ collectFromRules(nested, out, depth + 1);
75
+ }
76
+ }
77
+ function getHoverBaseSelectors() {
78
+ const sheets = document.styleSheets;
79
+ if (sheets.length === cachedSheetCount)
80
+ return hoverBaseSelectors;
81
+ cachedSheetCount = sheets.length;
82
+ const selectors = [];
83
+ for (let i = 0; i < sheets.length; i++) {
84
+ let rules = null;
85
+ try {
86
+ rules = sheets[i].cssRules;
87
+ }
88
+ catch {
89
+ continue; // cross-origin / inaccessible stylesheet
90
+ }
91
+ if (!rules)
92
+ continue;
93
+ collectFromRules(rules, selectors, 0);
94
+ }
95
+ hoverBaseSelectors = selectors;
96
+ return selectors;
97
+ }
98
+ function matchesHoverRule(el) {
99
+ const selectors = getHoverBaseSelectors();
100
+ for (let i = 0; i < selectors.length; i++) {
101
+ try {
102
+ if (el.matches(selectors[i]))
103
+ return true;
104
+ }
105
+ catch {
106
+ // .matches() rejects malformed/unsupported selectors — ignore them.
107
+ }
108
+ }
109
+ return false;
110
+ }
111
+ /**
112
+ * True when `el` itself exposes hover-like behavior. Cheapest checks first.
113
+ * In `strict` mode bare `cursor:pointer` does NOT qualify — only a genuine
114
+ * hover effect (tooltip, menu/popover, or a matching `:hover` CSS rule).
115
+ */
116
+ function hasHoverAffordance(el, strict) {
117
+ // Hover-revealed UI (tooltip / menu / popover) — real hover behavior even
118
+ // when implemented in JS rather than a `:hover` rule.
119
+ if (el.hasAttribute("title") ||
120
+ el.hasAttribute("data-tooltip") ||
121
+ el.hasAttribute("aria-describedby")) {
122
+ return true;
123
+ }
124
+ const role = el.getAttribute("role");
125
+ if (el.hasAttribute("aria-haspopup") || (role && MENU_ROLES.has(role))) {
126
+ return true;
127
+ }
128
+ // A genuine CSS hover effect targets this element — the gold standard.
129
+ if (matchesHoverRule(el))
130
+ return true;
131
+ // cursor:pointer is broad (most clickable elements set it, with no actual
132
+ // hover effect) — only counts in non-strict mode.
133
+ if (!strict) {
134
+ try {
135
+ if (typeof window.getComputedStyle === "function") {
136
+ if (window.getComputedStyle(el).cursor === "pointer")
137
+ return true;
138
+ }
139
+ }
140
+ catch {
141
+ // getComputedStyle can throw on detached nodes — ignore.
142
+ }
143
+ }
144
+ return false;
145
+ }
146
+ /** Walk up from `start` to the nearest element with hover affordance. */
147
+ function resolveHoverAffordance(start, maxDepth, strict) {
148
+ let el = start;
149
+ let depth = 0;
150
+ while (el && depth <= maxDepth) {
151
+ if (hasHoverAffordance(el, strict))
152
+ return el;
153
+ el = el.parentElement;
154
+ depth++;
155
+ }
156
+ return null;
157
+ }
158
+ /**
159
+ * Confirm the cursor is actually still over `el` at emit time. `:hover` matches
160
+ * an element when the pointer is over it OR a descendant — exactly what we want
161
+ * (we track the affordance ancestor). If the environment can't evaluate `:hover`
162
+ * (older jsdom, etc.), don't drop the hover — we only got here via a real
163
+ * pointerover + dwell with no intervening pointerout.
164
+ */
165
+ function stillHovering(el) {
166
+ try {
167
+ return el.matches(":hover");
168
+ }
169
+ catch {
170
+ return true;
171
+ }
172
+ }
173
+ /** Simple token bucket: `capacity` burst, refills `refillPerSec` tokens/sec. */
174
+ class TokenBucket {
175
+ capacity;
176
+ refillPerSec;
177
+ tokens;
178
+ last;
179
+ constructor(capacity, refillPerSec, now) {
180
+ this.capacity = capacity;
181
+ this.refillPerSec = refillPerSec;
182
+ this.tokens = capacity;
183
+ this.last = now;
184
+ }
185
+ tryConsume(now) {
186
+ this.tokens = Math.min(this.capacity, this.tokens + ((now - this.last) / 1000) * this.refillPerSec);
187
+ this.last = now;
188
+ if (this.tokens < 1)
189
+ return false;
190
+ this.tokens -= 1;
191
+ return true;
192
+ }
193
+ }
194
+ const COOLDOWN_LRU_CAP = 500;
195
+ // Single detector per document. If the recorder re-initializes (reconnect, SPA
196
+ // re-init, bfcache pageshow re-arm), a second install would otherwise STACK a
197
+ // second set of capture-phase listeners on top of the first — double-emitting
198
+ // every hover and leaking the originals. Tear down the previous install first.
199
+ let activeTeardown = null;
200
+ /**
201
+ * Install document-level hover detection. Returns a teardown function. No-op
202
+ * (returns a no-op teardown) outside the browser. Idempotent: a second call
203
+ * tears down the previous install before arming a new one.
204
+ */
205
+ export function installHoverDetection(record, options) {
206
+ if (typeof window === "undefined" || typeof document === "undefined") {
207
+ return () => { };
208
+ }
209
+ // Reclaim any detector from a prior install before arming a new one.
210
+ if (activeTeardown) {
211
+ activeTeardown();
212
+ activeTeardown = null;
213
+ }
214
+ const dwellMs = Math.max(0, options.dwellMs);
215
+ const cooldownMs = Math.max(0, options.cooldownMs ?? 3000);
216
+ const maxPerSession = options.maxPerSession ?? 300;
217
+ const maxPerSecond = options.maxPerSecond ?? 1;
218
+ const maxDwellMs = options.maxDwellMs ?? 60_000;
219
+ const maxDepth = options.maxAncestorDepth ?? 5;
220
+ const strict = options.strictAffordance ?? true;
221
+ // Date.now() (ms) is plenty for dwell timing and is advanced by fake timers
222
+ // in tests; performance.now() isn't reliably faked.
223
+ const now = () => Date.now();
224
+ // ── volume guard state ──────────────────────────────────────────────────
225
+ const lastEmitById = new Map();
226
+ let sessionHoverCount = 0;
227
+ const bucket = new TokenBucket(Math.max(10, maxPerSecond), maxPerSecond, now());
228
+ const passesVolumeGate = (id, t) => {
229
+ if (sessionHoverCount >= maxPerSession)
230
+ return false;
231
+ const last = lastEmitById.get(id);
232
+ if (last !== undefined && t - last < cooldownMs)
233
+ return false;
234
+ if (!bucket.tryConsume(t))
235
+ return false;
236
+ // record + LRU-cap the cooldown map
237
+ lastEmitById.delete(id);
238
+ lastEmitById.set(id, t);
239
+ if (lastEmitById.size > COOLDOWN_LRU_CAP) {
240
+ const oldest = lastEmitById.keys().next().value;
241
+ if (oldest !== undefined)
242
+ lastEmitById.delete(oldest);
243
+ }
244
+ sessionHoverCount += 1;
245
+ return true;
246
+ };
247
+ // ── per-hover tracking state ────────────────────────────────────────────
248
+ let trackedEl = null;
249
+ let enterTime = 0;
250
+ let dwellReached = false;
251
+ let dwellTimer = null;
252
+ let capTimer = null;
253
+ const clearTimers = () => {
254
+ if (dwellTimer !== null) {
255
+ clearTimeout(dwellTimer);
256
+ dwellTimer = null;
257
+ }
258
+ if (capTimer !== null) {
259
+ clearTimeout(capTimer);
260
+ capTimer = null;
261
+ }
262
+ };
263
+ const reset = () => {
264
+ clearTimers();
265
+ trackedEl = null;
266
+ dwellReached = false;
267
+ };
268
+ const emit = (el, startedAt, endedAt) => {
269
+ const id = record.mirror?.getId?.(el) ?? -1;
270
+ if (!id || id <= 0)
271
+ return; // not in the rrweb mirror → backend can't resolve it
272
+ if (!passesVolumeGate(id, endedAt))
273
+ return;
274
+ record.addSailfishEvent?.(EventType.SailfishCustom, {
275
+ action: "hover",
276
+ element_id: id,
277
+ started_at: Math.round(startedAt),
278
+ ended_at: Math.round(endedAt),
279
+ // Clamp ≥ 0: Date.now() isn't monotonic, so a clock step could otherwise
280
+ // produce a negative span.
281
+ duration_ms: Math.max(0, Math.round(endedAt - startedAt)),
282
+ });
283
+ };
284
+ // End the current hover. `emitted-by` semantics: emit only for genuine ends
285
+ // (leave / switch / cap / flush) once dwell was reached. A click END
286
+ // suppresses (hover→click is just a click); a raced-out dwell never set
287
+ // dwellReached so it can't emit.
288
+ const endHover = (cause) => {
289
+ const el = trackedEl;
290
+ const reached = dwellReached;
291
+ const started = enterTime;
292
+ reset();
293
+ if (el && reached && cause !== "click") {
294
+ emit(el, started, now());
295
+ }
296
+ };
297
+ const startHover = (el) => {
298
+ reset();
299
+ trackedEl = el;
300
+ enterTime = now();
301
+ dwellReached = false;
302
+ dwellTimer = setTimeout(() => {
303
+ dwellTimer = null;
304
+ if (trackedEl && stillHovering(trackedEl)) {
305
+ dwellReached = true;
306
+ // Arm the hard cap so an indefinitely-parked cursor still emits once.
307
+ capTimer = setTimeout(() => {
308
+ capTimer = null;
309
+ endHover("cap");
310
+ }, Math.max(0, maxDwellMs - dwellMs));
311
+ }
312
+ else {
313
+ reset(); // entered then left before the dwell — no hover
314
+ }
315
+ }, dwellMs);
316
+ };
317
+ const onPointerOver = (event) => {
318
+ const target = event.target;
319
+ if (!(target instanceof Element))
320
+ return;
321
+ const affordanceEl = resolveHoverAffordance(target, maxDepth, strict);
322
+ if (!affordanceEl) {
323
+ if (trackedEl && !trackedEl.contains(target))
324
+ endHover("leave");
325
+ return;
326
+ }
327
+ if (affordanceEl === trackedEl)
328
+ return; // already tracking it
329
+ if (trackedEl)
330
+ endHover("switch"); // moved A→B: A's hover ends (may emit)
331
+ startHover(affordanceEl);
332
+ };
333
+ const onPointerOut = (event) => {
334
+ const related = event.relatedTarget;
335
+ if (trackedEl &&
336
+ (!(related instanceof Node) || !trackedEl.contains(related))) {
337
+ endHover("leave");
338
+ }
339
+ };
340
+ const onPointerDown = (event) => {
341
+ const target = event.target;
342
+ if (trackedEl &&
343
+ target instanceof Node &&
344
+ (trackedEl === target || trackedEl.contains(target))) {
345
+ endHover("click"); // suppress — this hover became a click
346
+ }
347
+ };
348
+ const onVisibility = () => {
349
+ if (document.visibilityState === "hidden")
350
+ endHover("flush");
351
+ };
352
+ const capture = { capture: true, passive: true };
353
+ document.addEventListener("pointerover", onPointerOver, capture);
354
+ document.addEventListener("pointerout", onPointerOut, capture);
355
+ document.addEventListener("pointerdown", onPointerDown, capture);
356
+ document.addEventListener("visibilitychange", onVisibility, true);
357
+ window.addEventListener("pagehide", onVisibility, true);
358
+ const teardown = () => {
359
+ endHover("flush");
360
+ reset();
361
+ document.removeEventListener("pointerover", onPointerOver, { capture: true });
362
+ document.removeEventListener("pointerout", onPointerOut, { capture: true });
363
+ document.removeEventListener("pointerdown", onPointerDown, { capture: true });
364
+ document.removeEventListener("visibilitychange", onVisibility, true);
365
+ window.removeEventListener("pagehide", onVisibility, true);
366
+ if (activeTeardown === teardown)
367
+ activeTeardown = null;
368
+ };
369
+ activeTeardown = teardown;
370
+ return teardown;
371
+ }
Binary file
Binary file
@@ -0,0 +1,187 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { installHoverDetection } from "./hoverDetection";
3
+ // EventType.SailfishCustom === 25 (must match the backend's SailfishCustomEvent).
4
+ const SAILFISH_CUSTOM = 25;
5
+ function makeRecord(id = 42) {
6
+ return {
7
+ addSailfishEvent: vi.fn(),
8
+ mirror: { getId: vi.fn((_node) => id) },
9
+ };
10
+ }
11
+ // Affordance via the `title` attribute (cheapest path, no CSS needed in jsdom).
12
+ // We stub matches() so the `:hover` recheck is deterministic regardless of how
13
+ // jsdom handles the dynamic `:hover` pseudo-class.
14
+ function makeHoverable(hovering = true) {
15
+ const el = document.createElement("button");
16
+ el.setAttribute("title", "Help");
17
+ vi.spyOn(el, "matches").mockReturnValue(hovering);
18
+ document.body.appendChild(el);
19
+ return el;
20
+ }
21
+ const over = (el) => el.dispatchEvent(new Event("pointerover", { bubbles: true }));
22
+ const out = (el) => el.dispatchEvent(new Event("pointerout", { bubbles: true })); // null relatedTarget => "left"
23
+ const down = (el) => el.dispatchEvent(new Event("pointerdown", { bubbles: true }));
24
+ describe("installHoverDetection (v2)", () => {
25
+ let teardown = () => { };
26
+ beforeEach(() => {
27
+ vi.useFakeTimers();
28
+ document.body.innerHTML = "";
29
+ });
30
+ afterEach(() => {
31
+ teardown();
32
+ teardown = () => { };
33
+ vi.useRealTimers();
34
+ vi.restoreAllMocks();
35
+ });
36
+ it("emits once at hover-END with a duration, only after the dwell", () => {
37
+ const el = makeHoverable(true);
38
+ const record = makeRecord();
39
+ teardown = installHoverDetection(record, { dwellMs: 500 });
40
+ over(el);
41
+ vi.advanceTimersByTime(500); // dwell reached, but no end yet
42
+ expect(record.addSailfishEvent).not.toHaveBeenCalled();
43
+ vi.advanceTimersByTime(300); // dwell another 300ms
44
+ out(el); // hover ends -> emit
45
+ expect(record.addSailfishEvent).toHaveBeenCalledTimes(1);
46
+ const [type, payload] = record.addSailfishEvent.mock.calls[0];
47
+ expect(type).toBe(SAILFISH_CUSTOM);
48
+ expect(payload.action).toBe("hover");
49
+ expect(payload.element_id).toBe(42);
50
+ expect(payload.duration_ms).toBeGreaterThanOrEqual(800);
51
+ // start + end are explicit epoch-ms; end − start === duration.
52
+ expect(typeof payload.started_at).toBe("number");
53
+ expect(typeof payload.ended_at).toBe("number");
54
+ expect(payload.ended_at - payload.started_at).toBe(payload.duration_ms);
55
+ });
56
+ it("does NOT emit if the cursor is no longer hovering at the dwell mark", () => {
57
+ const el = makeHoverable(false); // matches(":hover") === false at recheck
58
+ const record = makeRecord();
59
+ teardown = installHoverDetection(record, { dwellMs: 500 });
60
+ over(el);
61
+ vi.advanceTimersByTime(500);
62
+ out(el);
63
+ expect(record.addSailfishEvent).not.toHaveBeenCalled();
64
+ });
65
+ it("suppresses the hover when it ends in a click on the same element", () => {
66
+ const el = makeHoverable(true);
67
+ const record = makeRecord();
68
+ teardown = installHoverDetection(record, { dwellMs: 500 });
69
+ over(el);
70
+ vi.advanceTimersByTime(500); // dwell reached
71
+ down(el); // click -> suppress
72
+ out(el);
73
+ expect(record.addSailfishEvent).not.toHaveBeenCalled();
74
+ });
75
+ it("strict mode (default) ignores cursor:pointer-only elements", () => {
76
+ const div = document.createElement("div"); // no tooltip / menu / :hover rule
77
+ document.body.appendChild(div);
78
+ vi.spyOn(window, "getComputedStyle").mockReturnValue({
79
+ cursor: "pointer",
80
+ });
81
+ const record = makeRecord();
82
+ teardown = installHoverDetection(record, { dwellMs: 100 }); // strict default
83
+ over(div);
84
+ vi.advanceTimersByTime(200);
85
+ out(div);
86
+ expect(record.addSailfishEvent).not.toHaveBeenCalled();
87
+ });
88
+ it("non-strict mode accepts cursor:pointer as affordance", () => {
89
+ const div = document.createElement("div");
90
+ vi.spyOn(div, "matches").mockReturnValue(true); // :hover recheck passes
91
+ document.body.appendChild(div);
92
+ vi.spyOn(window, "getComputedStyle").mockReturnValue({
93
+ cursor: "pointer",
94
+ });
95
+ const record = makeRecord();
96
+ teardown = installHoverDetection(record, {
97
+ dwellMs: 100,
98
+ strictAffordance: false,
99
+ });
100
+ over(div);
101
+ vi.advanceTimersByTime(100);
102
+ out(div);
103
+ expect(record.addSailfishEvent).toHaveBeenCalledTimes(1);
104
+ });
105
+ it("does not emit for elements without hover affordance", () => {
106
+ const div = document.createElement("div");
107
+ document.body.appendChild(div);
108
+ const record = makeRecord();
109
+ teardown = installHoverDetection(record, { dwellMs: 500 });
110
+ over(div);
111
+ vi.advanceTimersByTime(1000);
112
+ out(div);
113
+ expect(record.addSailfishEvent).not.toHaveBeenCalled();
114
+ });
115
+ it("dedups repeated hovers on the same element within the cooldown", () => {
116
+ const el = makeHoverable(true);
117
+ const record = makeRecord();
118
+ teardown = installHoverDetection(record, {
119
+ dwellMs: 100,
120
+ cooldownMs: 3000,
121
+ });
122
+ // First hover cycle -> emits.
123
+ over(el);
124
+ vi.advanceTimersByTime(100);
125
+ out(el);
126
+ // Second hover cycle on the same element, well within cooldown -> suppressed.
127
+ over(el);
128
+ vi.advanceTimersByTime(100);
129
+ out(el);
130
+ expect(record.addSailfishEvent).toHaveBeenCalledTimes(1);
131
+ });
132
+ it("enforces the per-session cap", () => {
133
+ const record = makeRecord();
134
+ teardown = installHoverDetection(record, {
135
+ dwellMs: 100,
136
+ cooldownMs: 0,
137
+ maxPerSession: 1,
138
+ maxPerSecond: 1000,
139
+ });
140
+ const a = makeHoverable(true);
141
+ const b = makeHoverable(true);
142
+ record.mirror.getId.mockImplementation((n) => (n === a ? 1 : 2));
143
+ over(a);
144
+ vi.advanceTimersByTime(100);
145
+ out(a); // emits (1/1)
146
+ over(b);
147
+ vi.advanceTimersByTime(100);
148
+ out(b); // capped
149
+ expect(record.addSailfishEvent).toHaveBeenCalledTimes(1);
150
+ });
151
+ it("emits on the hard dwell cap when the cursor is parked indefinitely", () => {
152
+ const el = makeHoverable(true);
153
+ const record = makeRecord();
154
+ teardown = installHoverDetection(record, {
155
+ dwellMs: 500,
156
+ maxDwellMs: 5000,
157
+ });
158
+ over(el);
159
+ vi.advanceTimersByTime(5000); // never leaves; cap fires
160
+ expect(record.addSailfishEvent).toHaveBeenCalledTimes(1);
161
+ expect(record.addSailfishEvent.mock.calls[0][1].duration_ms).toBeGreaterThanOrEqual(5000);
162
+ });
163
+ it("skips emission when the element is not in the rrweb mirror", () => {
164
+ const el = makeHoverable(true);
165
+ const record = makeRecord(-1); // getId returns -1 (unmapped)
166
+ teardown = installHoverDetection(record, { dwellMs: 100 });
167
+ over(el);
168
+ vi.advanceTimersByTime(100);
169
+ out(el);
170
+ expect(record.addSailfishEvent).not.toHaveBeenCalled();
171
+ });
172
+ it("is idempotent: re-installing tears down the prior detector (no double-emit)", () => {
173
+ const el = makeHoverable(true);
174
+ // First install, then a second install on the SAME document (simulates a
175
+ // recorder re-init). The second must reclaim the first's listeners so a
176
+ // single hover fires exactly once, not once per stacked detector.
177
+ const record1 = makeRecord();
178
+ installHoverDetection(record1, { dwellMs: 100 });
179
+ const record2 = makeRecord();
180
+ teardown = installHoverDetection(record2, { dwellMs: 100 });
181
+ over(el);
182
+ vi.advanceTimersByTime(100);
183
+ out(el);
184
+ expect(record1.addSailfishEvent).not.toHaveBeenCalled(); // old detector gone
185
+ expect(record2.addSailfishEvent).toHaveBeenCalledTimes(1); // only the new one
186
+ });
187
+ });
Binary file
Binary file
@@ -1291,6 +1291,13 @@ function bindListeners() {
1291
1291
  // skipped. The title is already validated above; priority always has
1292
1292
  // a default selection. (Done before closeModal so the inputs still
1293
1293
  // exist to probe.)
1294
+ //
1295
+ // IMPORTANT: only validate these when an eng ticket is actually being
1296
+ // created. The eng-ticket fields stay in the DOM (hidden via
1297
+ // display:none) when "Create an Eng Ticket" is unchecked, so without
1298
+ // this gate a connected integration's required fields (e.g. Jira's
1299
+ // "Reporter") get flagged on an issue-only submit even though they are
1300
+ // not visible and not used downstream.
1294
1301
  const isFieldEmpty = (v) => {
1295
1302
  if (v === undefined || v === null)
1296
1303
  return true;
@@ -1300,7 +1307,9 @@ function bindListeners() {
1300
1307
  return v.length === 0;
1301
1308
  return false;
1302
1309
  };
1303
- const requiredFields = (getFieldsForProject(engTicketProject, engTicketIssueType) || []).filter((f) => f && f.required);
1310
+ const requiredFields = currentState.createEngTicket
1311
+ ? (getFieldsForProject(engTicketProject, engTicketIssueType) || []).filter((f) => f && f.required)
1312
+ : [];
1304
1313
  const missingRequired = [];
1305
1314
  for (const f of requiredFields) {
1306
1315
  const fid = f.fieldId || f.key;
package/dist/index.js CHANGED
@@ -84,6 +84,14 @@ export const DEFAULT_CAPTURE_SETTINGS = {
84
84
  blockSelector: "",
85
85
  unmaskSelector: "",
86
86
  maskInputOptions: { password: true },
87
+ // Hover action (DIAGPLAT-1790): on by default; dwell ≈ when a tooltip/label
88
+ // would appear. All tunable per-account via the captureSettings API payload.
89
+ recordHover: true,
90
+ hoverDwellMs: 500,
91
+ hoverStrictAffordance: true,
92
+ hoverCooldownMs: 3000,
93
+ hoverMaxPerSession: 300,
94
+ hoverMaxPerSecond: 1,
87
95
  };
88
96
  export const DEFAULT_CONSOLE_RECORDING_SETTINGS = {
89
97
  level: ["info", "log", "warn", "error"],
package/dist/index.js.br CHANGED
Binary file
package/dist/index.js.gz CHANGED
Binary file
package/dist/recorder.cjs CHANGED
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
- const e = require("./chunks/index-BIj4l3CV.js");
3
+ const e = require("./chunks/index-CPZwt1ko.js");
4
4
  require("./chunks/patch2d-9voowhJ_.js"), exports.DEFAULT_CAPTURE_SETTINGS = e.DEFAULT_CAPTURE_SETTINGS, exports.DEFAULT_CONSOLE_RECORDING_SETTINGS = e.DEFAULT_CONSOLE_RECORDING_SETTINGS, exports.STORAGE_VERSION = e.STORAGE_VERSION, exports.addOrUpdateMetadata = e.addOrUpdateMetadata, exports.buildBatches = e.buildBatches, exports.clearStaleFuncSpanState = e.clearStaleFuncSpanState, exports.createSkipHeadersPropagationChecker = e.createSkipHeadersPropagationChecker, exports.createTriageAndIssueFromRecorder = e.createTriageAndIssueFromRecorder, exports.createTriageFromRecorder = e.createTriageFromRecorder, exports.disableFunctionSpanTracking = e.disableFunctionSpanTracking, exports.enableFunctionSpanTracking = e.enableFunctionSpanTracking, exports.ensureHrefCache = e.ensureHrefCache, exports.eventSize = e.eventSize, exports.fetchAndSendIp = e.fetchAndSendIp, exports.fetchCaptureSettings = e.fetchCaptureSettings, exports.fetchEngineeringTicketPlatformIntegrations = e.fetchEngineeringTicketPlatformIntegrations, exports.fetchFunctionSpanTrackingEnabled = e.fetchFunctionSpanTrackingEnabled, exports.flushBufferedEvents = e.flushBufferedEvents, exports.getBinaryDropCount = e.getBinaryDropCount, exports.getBufferedAmount = e.getBufferedAmount, exports.getCachedHref = e.getCachedHref, exports.getCachedHrefNoQuery = e.getCachedHrefNoQuery, exports.getFuncSpanHeader = e.getFuncSpanHeader, exports.getIdentifiedUser = e.getIdentifiedUser, exports.getOrSetSessionId = e.getOrSetSessionId, exports.getUrlAndStoredUuids = e.getUrlAndStoredUuids, exports.identify = e.identify, exports.initRecorder = e.initRecorder, exports.initializeConsolePlugin = e.initializeConsolePlugin, exports.initializeDomContentEvents = e.initializeDomContentEvents, exports.initializeFunctionSpanTrackingFromApi = e.initializeFunctionSpanTrackingFromApi, exports.initializePerformancePlugin = e.initializePerformancePlugin, exports.initializeRecording = e.initializeRecording, exports.initializeWebSocket = e.initializeWebSocket, exports.invalidateUrlCache = e.invalidateUrlCache, exports.isFunctionSpanTrackingEnabled = e.isFunctionSpanTrackingEnabled, exports.isTransportSaturated = e.isTransportSaturated, exports.maskCanvasDraws = e.maskCanvasDraws, exports.maskInputFn = e.maskInputFn, exports.matchUrlWithWildcard = e.matchUrlWithWildcard, Object.defineProperty(exports, "nowTimestamp", { enumerable: true, get: () => e.nowTimestamp }), exports.onNavigationChange = e.onNavigationChange, exports.openReportIssueModal = e.openReportIssueModal, exports.registerChart = e.registerChart, exports.registerFabricCanvas = e.registerFabricCanvas, exports.registerKonvaStage = e.registerKonvaStage, exports.registerPixiApp = e.registerPixiApp, exports.registerThreeScene = e.registerThreeScene, exports.requestTimeSync = e.requestTimeSync, exports.restoreFuncSpanState = e.restoreFuncSpanState, exports.sendDomainsToNotPropagateHeaderTo = e.sendDomainsToNotPropagateHeaderTo, exports.sendEvent = e.sendEvent, exports.sendGraphQLRequest = e.sendGraphQLRequest, exports.sendMessage = e.sendMessage, exports.startRecording = e.startRecording, exports.startRecordingSession = e.startRecordingSession, exports.toAbsoluteUrl = e.toAbsoluteUrl, exports.trackingEvent = e.trackingEvent, exports.withAppUrlMetadata = e.withAppUrlMetadata, exports.wsSendBinary = e.wsSendBinary;
Binary file
Binary file
package/dist/recorder.js CHANGED
@@ -1,4 +1,4 @@
1
- import { D, a, S, b, c, d, e, f, g, h, j, k, l, m, n, o, p, q, r, s, t, v, w, x, z, A, B, C, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, T, U, V, W, X, Y, Z, _, $, a0, a1, a2, a3, a4, a5, a6, a7, a8 } from "./chunks/index-DIYomvdD.js";
1
+ import { D, a, S, b, c, d, e, f, g, h, j, k, l, m, n, o, p, q, r, s, t, v, w, x, z, A, B, C, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, T, U, V, W, X, Y, Z, _, $, a0, a1, a2, a3, a4, a5, a6, a7, a8 } from "./chunks/index-BUAmLDnY.js";
2
2
  import "./chunks/patch2d-PQxcaE6r.js";
3
3
  export {
4
4
  D as DEFAULT_CAPTURE_SETTINGS,
Binary file
Binary file