@copilotkit/learning 0.0.1 → 1.81.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.
- package/README.md +63 -3
- package/dist/index.cjs +335 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -0
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +6 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +335 -34
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/scripts/check-size.mjs +2 -2
- package/src/__tests__/clicks.test.ts +499 -1
- package/src/__tests__/collector.test.ts +152 -0
- package/src/__tests__/inputs.test.ts +400 -1
- package/src/__tests__/redact.test.ts +4 -2
- package/src/__tests__/trajectory-collector.test.ts +105 -0
- package/src/clicks.ts +308 -3
- package/src/collector.ts +11 -5
- package/src/inputs.ts +167 -19
- package/src/redact.ts +7 -2
- package/src/trajectory-collector.ts +16 -2
- package/src/types.ts +6 -0
- package/CHANGELOG.md +0 -9
|
@@ -134,6 +134,7 @@ describe("Trajectory capture contract", () => {
|
|
|
134
134
|
const input = document.querySelector("input")!;
|
|
135
135
|
input.value = "synthetic@example.com";
|
|
136
136
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
137
|
+
vi.advanceTimersByTime(300);
|
|
137
138
|
expect(send.mock.calls[0]?.[0].value).toMatchObject({
|
|
138
139
|
route: "/deals",
|
|
139
140
|
url: expect.stringContaining("?email="),
|
|
@@ -365,3 +366,107 @@ describe("Trajectory capture contract", () => {
|
|
|
365
366
|
expect(vi.getTimerCount()).toBe(0);
|
|
366
367
|
});
|
|
367
368
|
});
|
|
369
|
+
|
|
370
|
+
it.each(["custom", "click", "navigation", "stop", "pagehide"])(
|
|
371
|
+
"flushes pending text before %s",
|
|
372
|
+
(next) => {
|
|
373
|
+
const { collector, send } = setup();
|
|
374
|
+
document.body.innerHTML = '<input id="notes"><button>Approve</button>';
|
|
375
|
+
collector.start();
|
|
376
|
+
const input = document.querySelector("input")!;
|
|
377
|
+
input.value = "final notes";
|
|
378
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
379
|
+
expect(send.mock.calls.map(([event]) => event.name)).toEqual(["page"]);
|
|
380
|
+
if (next === "custom") collector.emit("order.approved", { total: 480 });
|
|
381
|
+
else if (next === "click") document.querySelector("button")!.click();
|
|
382
|
+
else if (next === "navigation") history.pushState(null, "", "/next");
|
|
383
|
+
else if (next === "pagehide") window.dispatchEvent(new Event("pagehide"));
|
|
384
|
+
else collector.stop();
|
|
385
|
+
const events = send.mock.calls.map(([event]) => event);
|
|
386
|
+
expect(events.map((event) => event.name)).toEqual([
|
|
387
|
+
"page",
|
|
388
|
+
"input",
|
|
389
|
+
...(next === "custom"
|
|
390
|
+
? ["order.approved"]
|
|
391
|
+
: next === "click" || next === "navigation"
|
|
392
|
+
? [next]
|
|
393
|
+
: []),
|
|
394
|
+
]);
|
|
395
|
+
expect(events[1]?.value).toMatchObject({
|
|
396
|
+
route: "/deals",
|
|
397
|
+
target: { value: "final notes" },
|
|
398
|
+
});
|
|
399
|
+
collector.stop();
|
|
400
|
+
vi.advanceTimersByTime(1000);
|
|
401
|
+
expect(send).toHaveBeenCalledTimes(events.length);
|
|
402
|
+
},
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
it("network capture does not flush or delay pending text in the trajectory collector", async () => {
|
|
406
|
+
const originalFetch = globalThis.fetch;
|
|
407
|
+
globalThis.fetch = vi.fn(async () => new Response(null, { status: 204 }));
|
|
408
|
+
const { collector, send } = setup();
|
|
409
|
+
document.body.innerHTML = "<input>";
|
|
410
|
+
try {
|
|
411
|
+
collector.start();
|
|
412
|
+
const input = document.querySelector("input")!;
|
|
413
|
+
input.value = "pending search";
|
|
414
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
415
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
416
|
+
|
|
417
|
+
await fetch("/typeahead?q=pending");
|
|
418
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
419
|
+
|
|
420
|
+
expect(send.mock.calls.map(([event]) => event.name)).toEqual([
|
|
421
|
+
"page",
|
|
422
|
+
"network",
|
|
423
|
+
]);
|
|
424
|
+
await vi.advanceTimersByTimeAsync(199);
|
|
425
|
+
expect(send.mock.calls.map(([event]) => event.name)).toEqual([
|
|
426
|
+
"page",
|
|
427
|
+
"network",
|
|
428
|
+
]);
|
|
429
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
430
|
+
expect(send.mock.calls.map(([event]) => event.name)).toEqual([
|
|
431
|
+
"page",
|
|
432
|
+
"network",
|
|
433
|
+
"input",
|
|
434
|
+
]);
|
|
435
|
+
expect(send.mock.calls[2]?.[0]).toMatchObject({
|
|
436
|
+
timestamp: NOW + 300,
|
|
437
|
+
value: { target: { value: "pending search" } },
|
|
438
|
+
});
|
|
439
|
+
} finally {
|
|
440
|
+
collector.stop();
|
|
441
|
+
globalThis.fetch = originalFetch;
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it.each(["emit", "stop"])(
|
|
446
|
+
"a beforeSend stop/restart during %s cannot carry an old event into the new capture",
|
|
447
|
+
(action) => {
|
|
448
|
+
const { collector, send } = setup({
|
|
449
|
+
beforeSend(event) {
|
|
450
|
+
if (event.name === "input") {
|
|
451
|
+
collector.stop();
|
|
452
|
+
collector.start();
|
|
453
|
+
}
|
|
454
|
+
return event;
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
document.body.innerHTML = "<input>";
|
|
458
|
+
collector.start();
|
|
459
|
+
document.querySelector("input")!.value = "pending";
|
|
460
|
+
document
|
|
461
|
+
.querySelector("input")!
|
|
462
|
+
.dispatchEvent(new Event("input", { bubbles: true }));
|
|
463
|
+
if (action === "emit") collector.emit("old.action", {});
|
|
464
|
+
else collector.stop();
|
|
465
|
+
expect(send.mock.calls.map(([event]) => event.name)).toEqual([
|
|
466
|
+
"page",
|
|
467
|
+
"page",
|
|
468
|
+
]);
|
|
469
|
+
collector.emit("new.action", {});
|
|
470
|
+
expect(send.mock.calls.at(-1)?.[0].name).toBe("new.action");
|
|
471
|
+
},
|
|
472
|
+
);
|
package/src/clicks.ts
CHANGED
|
@@ -66,6 +66,298 @@ export function describeTarget(el: Element, redact: Redactor) {
|
|
|
66
66
|
return described;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
const MAX_LABEL_LENGTH = 80;
|
|
70
|
+
// The text walk stops past these bounds. For a control's own text, that means no name.
|
|
71
|
+
const MAX_WALK_NODES = 1000;
|
|
72
|
+
const MAX_WALK_CHARS = 4 * MAX_LABEL_LENGTH;
|
|
73
|
+
// Controls whose name comes from their content. An explicit role overrides the
|
|
74
|
+
// tag, so `<li role="option">` qualifies and `<a href role="row">` does not.
|
|
75
|
+
const CONTROL_ROLE =
|
|
76
|
+
/^(button|link|tab|menuitem(checkbox|radio)?|option|checkbox|switch|radio|treeitem)$/;
|
|
77
|
+
const CONTENTEDITABLE = '[contenteditable]:not([contenteditable="false"])';
|
|
78
|
+
const TEXT_ENTRY = `${CONTENTEDITABLE}, [role~=textbox], [role~=searchbox], [role~=combobox]`;
|
|
79
|
+
const EDITABLE = `input, textarea, select, ${TEXT_ENTRY}`;
|
|
80
|
+
// Card-like structure: a control containing it gets no name from its text,
|
|
81
|
+
// except for one lone paragraph, or headings inside `summary`.
|
|
82
|
+
const HEADING = "h1, h2, h3, h4, h5, h6, [role~=heading]";
|
|
83
|
+
const STRUCTURE = `${HEADING}, p, ul, ol, li, dl, table, article, section, blockquote`;
|
|
84
|
+
const SKIPPED = 'script, style, template, [hidden], [aria-hidden="true"]';
|
|
85
|
+
// Inline elements that do not separate words.
|
|
86
|
+
const PHRASING =
|
|
87
|
+
/^(b|i|u|em|strong|mark|small|sub|sup|abbr|code|kbd|s|q|cite|dfn|var|time|data|bdi|bdo|wbr|span)$/;
|
|
88
|
+
const CAPTION_INPUT = /^(submit|button|reset|image)$/;
|
|
89
|
+
// Input types that do not hold typed text.
|
|
90
|
+
const NON_TEXT_INPUT =
|
|
91
|
+
/^(submit|button|reset|image|checkbox|radio|range|color|file|hidden)$/;
|
|
92
|
+
// Includes bidi controls, zero-width characters, soft hyphens, word joiners,
|
|
93
|
+
// variation selectors, and tag characters.
|
|
94
|
+
const IGNORABLE = /\p{Default_Ignorable_Code_Point}/gu;
|
|
95
|
+
// Quantifier bounds and run-start anchors keep each pattern linear. No
|
|
96
|
+
// lookbehind: a regex literal with one fails to parse in older Safari.
|
|
97
|
+
// A URL ends before trailing sentence punctuation.
|
|
98
|
+
const URL_LIKE =
|
|
99
|
+
/(?:\b[a-z][\w+.-]{0,31}:\/\/|\bwww\.)[^\s<>"']*[^\s<>"'.,;:!?)]/gi;
|
|
100
|
+
const EMAIL =
|
|
101
|
+
/[\p{L}\p{N}_.%+-]{1,64}@[\p{L}\p{N}-]{1,63}(?:\.[\p{L}\p{N}-]{1,63})+/gu;
|
|
102
|
+
// JWTs, and three dotted segments that are each long or contain a digit, but
|
|
103
|
+
// not dotted names such as `settings.notifications.preferences`.
|
|
104
|
+
const JWT =
|
|
105
|
+
/(^|[^\w-])(?:eyJ[\w-]*\.[\w-]*\.[\w-]*|[\w-]{16,}\.[\w-]{16,}\.[\w-]{16,}|(?=[\w-]*\d)[\w-]{8,}\.(?=[\w-]*\d)[\w-]{8,}\.(?=[\w-]*\d)[\w-]{8,})/g;
|
|
106
|
+
const TOKEN_RUN = /[\w+/=-]{24,}/g;
|
|
107
|
+
|
|
108
|
+
const roleOf = (element: Element) =>
|
|
109
|
+
(element.getAttribute("role") ?? "").trim().split(/\s+/)[0]!.toLowerCase();
|
|
110
|
+
|
|
111
|
+
const idRefs = (element: Element, name: string) =>
|
|
112
|
+
(element.getAttribute(name) ?? "").split(/\s+/).filter(Boolean);
|
|
113
|
+
|
|
114
|
+
const rootOf = (element: Element) => {
|
|
115
|
+
const root = element.getRootNode();
|
|
116
|
+
return root instanceof Document || root instanceof ShadowRoot ? root : null;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Buttons, links with `href`, `summary`, and elements with a control role. */
|
|
120
|
+
const isControl = (element: Element) => {
|
|
121
|
+
const role = roleOf(element);
|
|
122
|
+
return role
|
|
123
|
+
? CONTROL_ROLE.test(role)
|
|
124
|
+
: element.matches("button, a[href], summary");
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Text-like inputs, text areas, selects, and anything inside editable content or a text-entry role. */
|
|
128
|
+
const holdsValue = (element: Element) =>
|
|
129
|
+
element instanceof HTMLInputElement
|
|
130
|
+
? !NON_TEXT_INPUT.test(element.type)
|
|
131
|
+
: element.matches("textarea, select") ||
|
|
132
|
+
element.closest(TEXT_ENTRY) !== null;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The name an image contributes: `alt` of an `img`, or `aria-label` (else a
|
|
136
|
+
* plain `<title>`) of an `svg` or `role="img"` element. `undefined` when the
|
|
137
|
+
* element is not an image.
|
|
138
|
+
*/
|
|
139
|
+
function imageName(element: Element) {
|
|
140
|
+
if (element.localName === "img") return element.getAttribute("alt");
|
|
141
|
+
if (element.localName !== "svg" && roleOf(element) !== "img")
|
|
142
|
+
return undefined;
|
|
143
|
+
const title = Array.from(element.children).find(
|
|
144
|
+
(child) => child.localName === "title" && child.childElementCount === 0,
|
|
145
|
+
);
|
|
146
|
+
return element.getAttribute("aria-label") ?? title?.textContent ?? null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Rendered text. Inline formatting joins words; other elements separate them.
|
|
151
|
+
* Skips hidden subtrees, uses image names, and does
|
|
152
|
+
* not walk into images. Yields nothing for editable content or editable
|
|
153
|
+
* descendants. In `strict` mode (a control's own text) it also yields nothing
|
|
154
|
+
* past the walk bounds, for card-like structure, and for text that looks like
|
|
155
|
+
* separate fields: two `div`s that directly hold text, or three sibling
|
|
156
|
+
* elements that each carry text.
|
|
157
|
+
*/
|
|
158
|
+
function safeText(root: Element, strict: boolean) {
|
|
159
|
+
if (holdsValue(root)) return "";
|
|
160
|
+
let text = "";
|
|
161
|
+
let nodes = 0;
|
|
162
|
+
let paragraphs = 0;
|
|
163
|
+
// The first div that holds text; a second one means separate fields.
|
|
164
|
+
let div: Element | undefined;
|
|
165
|
+
const bearers = new Map<Node, Set<Node>>();
|
|
166
|
+
const add = (part: string) => (text = (text + part).replace(/\s+/g, " "));
|
|
167
|
+
// Marks `start` and its ancestors below root as carrying text. False once
|
|
168
|
+
// three siblings do.
|
|
169
|
+
const bear = (start: Node) => {
|
|
170
|
+
if (!strict) return true;
|
|
171
|
+
for (
|
|
172
|
+
let child = start, parent = start.parentNode;
|
|
173
|
+
child !== root && parent !== null;
|
|
174
|
+
child = parent, parent = parent.parentNode
|
|
175
|
+
) {
|
|
176
|
+
const siblings = bearers.get(parent) ?? new Set<Node>();
|
|
177
|
+
bearers.set(parent, siblings);
|
|
178
|
+
if (siblings.has(child)) break;
|
|
179
|
+
if (siblings.add(child).size > 2) return false;
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
};
|
|
183
|
+
let node: Node | null = root.firstChild;
|
|
184
|
+
while (node !== null) {
|
|
185
|
+
if (++nodes > MAX_WALK_NODES || text.length > MAX_WALK_CHARS) {
|
|
186
|
+
if (strict) return "";
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
let descend = false;
|
|
190
|
+
if (node instanceof Text) {
|
|
191
|
+
const parent = node.parentNode as Element;
|
|
192
|
+
// The block that holds this text, through inline elements.
|
|
193
|
+
let holder = parent;
|
|
194
|
+
while (holder !== root && PHRASING.test(holder.localName))
|
|
195
|
+
holder = holder.parentElement!;
|
|
196
|
+
if (
|
|
197
|
+
/\S/.test(node.data) &&
|
|
198
|
+
(!bear(parent) ||
|
|
199
|
+
(strict && holder.localName === "div" && (div ??= holder) !== holder))
|
|
200
|
+
)
|
|
201
|
+
return "";
|
|
202
|
+
add(node.data);
|
|
203
|
+
} else if (node instanceof Element) {
|
|
204
|
+
if (node.matches(EDITABLE)) return "";
|
|
205
|
+
if (
|
|
206
|
+
strict &&
|
|
207
|
+
node.matches(STRUCTURE) &&
|
|
208
|
+
!(root.localName === "summary" && node.matches(HEADING)) &&
|
|
209
|
+
(node.localName !== "p" || ++paragraphs > 1)
|
|
210
|
+
)
|
|
211
|
+
return "";
|
|
212
|
+
const name = node.matches(SKIPPED) ? null : imageName(node);
|
|
213
|
+
if (name) {
|
|
214
|
+
if (!bear(node)) return "";
|
|
215
|
+
add(` ${name} `);
|
|
216
|
+
} else if (name === undefined) {
|
|
217
|
+
if (!PHRASING.test(node.localName)) add(" ");
|
|
218
|
+
descend = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (descend && node.firstChild !== null) node = node.firstChild;
|
|
222
|
+
else {
|
|
223
|
+
while (node !== root && node.nextSibling === null) {
|
|
224
|
+
node = node.parentNode!;
|
|
225
|
+
if (!PHRASING.test((node as Element).localName)) add(" ");
|
|
226
|
+
}
|
|
227
|
+
node = node === root ? null : node.nextSibling;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return text;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** An option or tree item of an autocomplete or search popup, whose text usually repeats what the user typed. */
|
|
234
|
+
const isSuggestion = (element: Element) =>
|
|
235
|
+
(element.localName === "option" ||
|
|
236
|
+
/^(option|treeitem)$/.test(roleOf(element))) &&
|
|
237
|
+
inSuggestionList(element);
|
|
238
|
+
|
|
239
|
+
/** Whether `element` is, or is inside, a combobox, a datalist, or the popup a combobox or autocomplete field controls or owns. */
|
|
240
|
+
function inSuggestionList(element: Element) {
|
|
241
|
+
if (element.closest("[role~=combobox], datalist") !== null) return true;
|
|
242
|
+
const root = rootOf(element);
|
|
243
|
+
return (
|
|
244
|
+
root !== null &&
|
|
245
|
+
Array.from(
|
|
246
|
+
root.querySelectorAll("[role~=combobox], [aria-autocomplete]"),
|
|
247
|
+
).some((owner) =>
|
|
248
|
+
[...idRefs(owner, "aria-controls"), ...idRefs(owner, "aria-owns")].some(
|
|
249
|
+
(id) => root.getElementById(id)?.contains(element),
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function labelledBy(element: Element) {
|
|
256
|
+
const root = rootOf(element);
|
|
257
|
+
return root === null
|
|
258
|
+
? null
|
|
259
|
+
: idRefs(element, "aria-labelledby")
|
|
260
|
+
.map((id) => {
|
|
261
|
+
const referenced = root.getElementById(id);
|
|
262
|
+
return referenced === null || inSuggestionList(referenced)
|
|
263
|
+
? ""
|
|
264
|
+
: safeText(referenced, false);
|
|
265
|
+
})
|
|
266
|
+
.join(" ");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** A control's own text, unless it is longer than a label: then it is content, not a name. */
|
|
270
|
+
const normalize = (text: string) =>
|
|
271
|
+
text.replace(IGNORABLE, "").replace(/\s+/g, " ").trim();
|
|
272
|
+
|
|
273
|
+
function contentText(element: Element) {
|
|
274
|
+
const text = safeText(element, true);
|
|
275
|
+
return Array.from(normalize(text)).length > MAX_LABEL_LENGTH ? "" : text;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Removes invisible characters, credentials, emails, URLs, and tokens, then caps the length. */
|
|
279
|
+
function cleanLabel(raw: string, redact: Redactor) {
|
|
280
|
+
const chars = Array.from(
|
|
281
|
+
// Scrub again after collapsing, also for remembered values that differ
|
|
282
|
+
// in whitespace or invisible characters.
|
|
283
|
+
redact
|
|
284
|
+
.scrub(
|
|
285
|
+
normalize(redact.body(raw.replace(IGNORABLE, "")) ?? ""),
|
|
286
|
+
normalize,
|
|
287
|
+
)
|
|
288
|
+
.replace(URL_LIKE, "[url]")
|
|
289
|
+
.replace(EMAIL, "[email]")
|
|
290
|
+
.replace(JWT, `$1${REDACTED}`)
|
|
291
|
+
.replace(TOKEN_RUN, (run) => (/\d/.test(run) ? REDACTED : run)),
|
|
292
|
+
);
|
|
293
|
+
return chars.length > MAX_LABEL_LENGTH
|
|
294
|
+
? `${chars
|
|
295
|
+
.slice(0, MAX_LABEL_LENGTH - 1)
|
|
296
|
+
.join("")
|
|
297
|
+
.trimEnd()}…`
|
|
298
|
+
: chars.join("");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function firstLabel(sources: (() => string | null)[], redact: Redactor) {
|
|
302
|
+
for (const source of sources) {
|
|
303
|
+
const label = cleanLabel(source() ?? "", redact);
|
|
304
|
+
if (label) return label;
|
|
305
|
+
}
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A short name for the clicked control, from the first source that yields one:
|
|
311
|
+
* `aria-labelledby`, `aria-label`, an input caption or `alt`, `title` (controls
|
|
312
|
+
* only), then the text of a control named by its content. Containers, cards,
|
|
313
|
+
* and editable content never contribute their text. An autocomplete suggestion,
|
|
314
|
+
* or anything inside one, gets no label from any source; `data-copilotkit-action`
|
|
315
|
+
* names it instead. An icon without its own name takes the name of the control
|
|
316
|
+
* around it.
|
|
317
|
+
*/
|
|
318
|
+
function clickLabel(element: Element, redact: Redactor): string | undefined {
|
|
319
|
+
const option = element.closest("option, [role~=option], [role~=treeitem]");
|
|
320
|
+
if (option !== null && isSuggestion(option)) return undefined;
|
|
321
|
+
// aria-labelledby and aria-label apply to any other target.
|
|
322
|
+
const named = [
|
|
323
|
+
() => labelledBy(element),
|
|
324
|
+
() => element.getAttribute("aria-label"),
|
|
325
|
+
];
|
|
326
|
+
if (
|
|
327
|
+
/^(img|svg)$/.test(element.localName) ||
|
|
328
|
+
/^(img|presentation|none)$/.test(roleOf(element))
|
|
329
|
+
) {
|
|
330
|
+
const own = firstLabel(
|
|
331
|
+
[...named, () => imageName(element) ?? null],
|
|
332
|
+
redact,
|
|
333
|
+
);
|
|
334
|
+
if (own !== undefined) return own;
|
|
335
|
+
// parentElement is null at a shadow root, so the search stays inside it.
|
|
336
|
+
let control = element.parentElement;
|
|
337
|
+
while (control !== null && !isControl(control))
|
|
338
|
+
control = control.parentElement;
|
|
339
|
+
return control === null ? undefined : clickLabel(control, redact);
|
|
340
|
+
}
|
|
341
|
+
const control = isControl(element) && !holdsValue(element);
|
|
342
|
+
const captionInput =
|
|
343
|
+
element instanceof HTMLInputElement && CAPTION_INPUT.test(element.type);
|
|
344
|
+
return firstLabel(
|
|
345
|
+
[
|
|
346
|
+
...named,
|
|
347
|
+
// The caption of a button-like input, or the `alt` of an image input.
|
|
348
|
+
() =>
|
|
349
|
+
captionInput
|
|
350
|
+
? element.type === "image"
|
|
351
|
+
? element.getAttribute("alt")
|
|
352
|
+
: element.value
|
|
353
|
+
: null,
|
|
354
|
+
() => (control || captionInput ? element.getAttribute("title") : null),
|
|
355
|
+
() => (control ? contentText(element) : null),
|
|
356
|
+
],
|
|
357
|
+
redact,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
69
361
|
/** Snapshot live control properties; HTML attributes do not reflect edited values. */
|
|
70
362
|
export function readControlValue(element: Element, redact: Redactor) {
|
|
71
363
|
if (element instanceof HTMLInputElement) {
|
|
@@ -96,7 +388,7 @@ export function readControlValue(element: Element, redact: Redactor) {
|
|
|
96
388
|
};
|
|
97
389
|
}
|
|
98
390
|
if (element instanceof HTMLTextAreaElement) return { value: element.value };
|
|
99
|
-
if (element.matches(
|
|
391
|
+
if (element.matches(CONTENTEDITABLE)) {
|
|
100
392
|
return { value: element.textContent ?? "" };
|
|
101
393
|
}
|
|
102
394
|
return {};
|
|
@@ -140,12 +432,25 @@ export function installClickCapture(params: {
|
|
|
140
432
|
} = params;
|
|
141
433
|
let origin: PointerOrigin | null = null;
|
|
142
434
|
|
|
435
|
+
// Input events share describeTarget; only clicks carry a label.
|
|
436
|
+
const describeClick = (el: Element) => {
|
|
437
|
+
const described = describeTarget(el, redact);
|
|
438
|
+
if (described === null) return null;
|
|
439
|
+
try {
|
|
440
|
+
const label = clickLabel(described.element, redact);
|
|
441
|
+
if (label !== undefined) described.target.label = label;
|
|
442
|
+
} catch {
|
|
443
|
+
// A failed label must not drop the click.
|
|
444
|
+
}
|
|
445
|
+
return described;
|
|
446
|
+
};
|
|
447
|
+
|
|
143
448
|
const onPointerDown = (event: PointerEvent) => {
|
|
144
449
|
try {
|
|
145
450
|
if (event.isPrimary === false) return;
|
|
146
451
|
const el = eventElement(event);
|
|
147
452
|
origin = {
|
|
148
|
-
described: el === null ? null :
|
|
453
|
+
described: el === null ? null : describeClick(el),
|
|
149
454
|
x: event.clientX,
|
|
150
455
|
y: event.clientY,
|
|
151
456
|
};
|
|
@@ -167,7 +472,7 @@ export function installClickCapture(params: {
|
|
|
167
472
|
isNear(pointerOrigin, event);
|
|
168
473
|
const described = canRecover
|
|
169
474
|
? pointerOrigin.described
|
|
170
|
-
:
|
|
475
|
+
: describeClick(el);
|
|
171
476
|
if (described === null) return;
|
|
172
477
|
const input = event.detail === 0 ? "keyboard" : "pointer";
|
|
173
478
|
emit("click", {
|
package/src/collector.ts
CHANGED
|
@@ -27,6 +27,7 @@ interface Session {
|
|
|
27
27
|
dropped: number;
|
|
28
28
|
uninstalls: (() => void)[];
|
|
29
29
|
timer: ReturnType<typeof setInterval> | undefined;
|
|
30
|
+
flushInputs?: () => void;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -79,6 +80,8 @@ export function createCollector(options: CollectorOptions) {
|
|
|
79
80
|
const record = (name: string, value: Record<string, unknown>) => {
|
|
80
81
|
const current = session;
|
|
81
82
|
if (current === null) return;
|
|
83
|
+
if (name !== "input" && name !== "network") current.flushInputs?.();
|
|
84
|
+
if (session !== current) return;
|
|
82
85
|
try {
|
|
83
86
|
seq += 1;
|
|
84
87
|
const event: LearningEvent = {
|
|
@@ -88,7 +91,7 @@ export function createCollector(options: CollectorOptions) {
|
|
|
88
91
|
timestamp: Date.now(),
|
|
89
92
|
};
|
|
90
93
|
const kept = beforeSend === undefined ? event : beforeSend(event);
|
|
91
|
-
if (kept === null) return;
|
|
94
|
+
if (kept === null || session !== current) return;
|
|
92
95
|
current.queue.push(kept);
|
|
93
96
|
if (current.queue.length >= FLUSH_SIZE) flush();
|
|
94
97
|
} catch {
|
|
@@ -146,10 +149,11 @@ export function createCollector(options: CollectorOptions) {
|
|
|
146
149
|
redact,
|
|
147
150
|
}),
|
|
148
151
|
);
|
|
149
|
-
if (capture.inputs)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
);
|
|
152
|
+
if (capture.inputs) {
|
|
153
|
+
const inputs = installInputCapture({ emit: recordCurrent, redact });
|
|
154
|
+
current.flushInputs = inputs.flush;
|
|
155
|
+
current.uninstalls.push(inputs);
|
|
156
|
+
}
|
|
153
157
|
const onPageHide = () => flush({ beacon: true });
|
|
154
158
|
window.addEventListener("pagehide", onPageHide);
|
|
155
159
|
current.uninstalls.push(() =>
|
|
@@ -167,6 +171,8 @@ export function createCollector(options: CollectorOptions) {
|
|
|
167
171
|
const stop = () => {
|
|
168
172
|
const current = session;
|
|
169
173
|
if (current === null) return;
|
|
174
|
+
current.flushInputs?.();
|
|
175
|
+
if (session !== current) return;
|
|
170
176
|
clearInterval(current.timer);
|
|
171
177
|
for (const uninstall of current.uninstalls) uninstall();
|
|
172
178
|
flush();
|