@copilotkit/learning 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/index.cjs +1134 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +173 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +173 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1126 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +45 -0
- package/scripts/check-size.mjs +33 -0
- package/src/__tests__/clicks.test.ts +202 -0
- package/src/__tests__/collector.test.ts +307 -0
- package/src/__tests__/http-sink.test.ts +72 -0
- package/src/__tests__/inputs.test.ts +77 -0
- package/src/__tests__/navigation.test.ts +117 -0
- package/src/__tests__/network.test.ts +609 -0
- package/src/__tests__/redact.test.ts +732 -0
- package/src/__tests__/routes.test.ts +26 -0
- package/src/__tests__/trajectory-collector.test.ts +367 -0
- package/src/__tests__/vitest-environment.mjs +23 -0
- package/src/clicks.ts +193 -0
- package/src/collector.ts +199 -0
- package/src/http-sink.ts +38 -0
- package/src/index.ts +30 -0
- package/src/inputs.ts +42 -0
- package/src/navigation.ts +123 -0
- package/src/network-body.ts +237 -0
- package/src/network.ts +394 -0
- package/src/redact.ts +257 -0
- package/src/routes.ts +14 -0
- package/src/trajectory-collector.ts +193 -0
- package/src/trajectory-types.ts +50 -0
- package/src/types.ts +98 -0
- package/tsconfig.json +16 -0
- package/tsdown.config.ts +11 -0
- package/vitest.config.mjs +9 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { toOriginAndRoute, toRoute } from "../routes";
|
|
3
|
+
|
|
4
|
+
describe("raw URLs", () => {
|
|
5
|
+
it("retains ID-like and encoded path segments even with legacy templates", () => {
|
|
6
|
+
const path = "/users/jane@example.com/orders/123/search/private%20search";
|
|
7
|
+
expect(toRoute(path, ["/users/:id/orders/:order/search/:query"])).toBe(
|
|
8
|
+
path,
|
|
9
|
+
);
|
|
10
|
+
expect(toRoute("/")).toBe("/");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("preserves the full URL and resolves relative URLs", () => {
|
|
14
|
+
expect(
|
|
15
|
+
toOriginAndRoute(
|
|
16
|
+
"/deals/9?token=synthetic#details",
|
|
17
|
+
["/deals/:id"],
|
|
18
|
+
"https://app.example.com/x",
|
|
19
|
+
),
|
|
20
|
+
).toEqual({
|
|
21
|
+
url: "https://app.example.com/deals/9?token=synthetic#details",
|
|
22
|
+
origin: "https://app.example.com",
|
|
23
|
+
route: "/deals/9",
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type * as ClickCapture from "../clicks";
|
|
3
|
+
import type * as InputCapture from "../inputs";
|
|
4
|
+
import {
|
|
5
|
+
createTrajectoryCollector,
|
|
6
|
+
MAX_TRAJECTORY_EVENT_BYTES,
|
|
7
|
+
} from "../trajectory-collector";
|
|
8
|
+
import type {
|
|
9
|
+
JsonValue,
|
|
10
|
+
TrajectoryCaptureOptions,
|
|
11
|
+
TrajectoryCollector,
|
|
12
|
+
TrajectoryEvent,
|
|
13
|
+
} from "../trajectory-types";
|
|
14
|
+
|
|
15
|
+
// jsdom cannot produce trusted clicks. The capture module itself remains real.
|
|
16
|
+
vi.mock("../clicks", async (importOriginal) => {
|
|
17
|
+
const actual = await importOriginal<typeof ClickCapture>();
|
|
18
|
+
return {
|
|
19
|
+
...actual,
|
|
20
|
+
installClickCapture: (
|
|
21
|
+
params: Parameters<typeof actual.installClickCapture>[0],
|
|
22
|
+
) => actual.installClickCapture({ ...params, isTrusted: () => true }),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
vi.mock("../inputs", async (importOriginal) => {
|
|
27
|
+
const actual = await importOriginal<typeof InputCapture>();
|
|
28
|
+
return {
|
|
29
|
+
...actual,
|
|
30
|
+
installInputCapture: (
|
|
31
|
+
params: Parameters<typeof actual.installInputCapture>[0],
|
|
32
|
+
) => actual.installInputCapture({ ...params, isTrusted: () => true }),
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const NOW = 1_790_866_801_000;
|
|
37
|
+
const collectors: TrajectoryCollector[] = [];
|
|
38
|
+
|
|
39
|
+
function setup(options: TrajectoryCaptureOptions = {}) {
|
|
40
|
+
const send = vi.fn<(event: TrajectoryEvent) => void>();
|
|
41
|
+
const onError = vi.fn();
|
|
42
|
+
const collector = createTrajectoryCollector({
|
|
43
|
+
routes: ["/deals", "/deals/:id"],
|
|
44
|
+
send,
|
|
45
|
+
onError,
|
|
46
|
+
...options,
|
|
47
|
+
});
|
|
48
|
+
collectors.push(collector);
|
|
49
|
+
return { collector, send, onError };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
vi.useFakeTimers();
|
|
54
|
+
vi.setSystemTime(NOW);
|
|
55
|
+
history.replaceState(null, "", "/deals?email=secret@example.com#private");
|
|
56
|
+
document.body.innerHTML =
|
|
57
|
+
'<div data-message-id="private-message"><button data-copilotkit-action="deal.open">Private deal text</button></div>';
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
collectors.splice(0).forEach((collector) => collector.stop());
|
|
62
|
+
document.body.innerHTML = "";
|
|
63
|
+
vi.useRealTimers();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("Trajectory capture contract", () => {
|
|
67
|
+
it("sends real page, click, and navigation capture as plain events without chat context", () => {
|
|
68
|
+
const fetchBefore = globalThis.fetch;
|
|
69
|
+
const xhrBefore = XMLHttpRequest.prototype.open;
|
|
70
|
+
const { collector, send } = setup();
|
|
71
|
+
collector.start();
|
|
72
|
+
document.querySelector("button")!.click();
|
|
73
|
+
history.pushState(null, "", "/deals/42?token=private#secret");
|
|
74
|
+
|
|
75
|
+
expect(send.mock.calls.map(([event]) => event)).toMatchObject([
|
|
76
|
+
{
|
|
77
|
+
type: "CUSTOM",
|
|
78
|
+
name: "page",
|
|
79
|
+
timestamp: NOW,
|
|
80
|
+
value: {
|
|
81
|
+
route: "/deals",
|
|
82
|
+
url: `${location.origin}/deals?email=secret@example.com#private`,
|
|
83
|
+
title: document.title,
|
|
84
|
+
referrer: document.referrer,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
type: "CUSTOM",
|
|
89
|
+
name: "click",
|
|
90
|
+
timestamp: NOW,
|
|
91
|
+
value: {
|
|
92
|
+
route: "/deals",
|
|
93
|
+
target: {
|
|
94
|
+
tag: "button",
|
|
95
|
+
role: null,
|
|
96
|
+
action: "deal.open",
|
|
97
|
+
text: "Private deal text",
|
|
98
|
+
attributes: { "data-copilotkit-action": "deal.open" },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
type: "CUSTOM",
|
|
104
|
+
name: "navigation",
|
|
105
|
+
timestamp: NOW,
|
|
106
|
+
value: {
|
|
107
|
+
from: `${location.origin}/deals?email=secret@example.com#private`,
|
|
108
|
+
to: `${location.origin}/deals/42?token=[redacted]#secret`,
|
|
109
|
+
navigationType: "push",
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
]);
|
|
113
|
+
expect(JSON.stringify(send.mock.calls)).not.toMatch(
|
|
114
|
+
/threadId|"seq"|trajectoryId|learningContainerIds/,
|
|
115
|
+
);
|
|
116
|
+
expect(globalThis.fetch).not.toBe(fetchBefore);
|
|
117
|
+
expect(XMLHttpRequest.prototype.open).not.toBe(xhrBefore);
|
|
118
|
+
collector.stop();
|
|
119
|
+
expect(globalThis.fetch).toBe(fetchBefore);
|
|
120
|
+
expect(XMLHttpRequest.prototype.open).toBe(xhrBefore);
|
|
121
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("retains unconfigured paths and user edits without Thread enrichment", () => {
|
|
125
|
+
const { collector, send } = setup({ routes: [] });
|
|
126
|
+
collector.start();
|
|
127
|
+
history.pushState(
|
|
128
|
+
null,
|
|
129
|
+
"",
|
|
130
|
+
"/users/alice/private-email@example.com?view=full#profile",
|
|
131
|
+
);
|
|
132
|
+
document.body.innerHTML =
|
|
133
|
+
'<input id="email" type="email" data-message-id="message-1">';
|
|
134
|
+
const input = document.querySelector("input")!;
|
|
135
|
+
input.value = "synthetic@example.com";
|
|
136
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
137
|
+
expect(send.mock.calls[0]?.[0].value).toMatchObject({
|
|
138
|
+
route: "/deals",
|
|
139
|
+
url: expect.stringContaining("?email="),
|
|
140
|
+
});
|
|
141
|
+
expect(send.mock.calls[1]?.[0].value).toMatchObject({
|
|
142
|
+
to: `${location.origin}/users/alice/private-email@example.com?view=full#profile`,
|
|
143
|
+
});
|
|
144
|
+
expect(send.mock.calls[2]?.[0]).toMatchObject({
|
|
145
|
+
name: "input",
|
|
146
|
+
value: {
|
|
147
|
+
eventType: "input",
|
|
148
|
+
target: {
|
|
149
|
+
value: "synthetic@example.com",
|
|
150
|
+
attributes: { "data-message-id": "message-1" },
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
expect(send.mock.calls[2]?.[0].value).not.toHaveProperty("threadId");
|
|
155
|
+
expect(send.mock.calls[2]?.[0].value).not.toHaveProperty("messageId");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("sends raw network bodies and excludes its configured transport URL", async () => {
|
|
159
|
+
const originalFetch = globalThis.fetch;
|
|
160
|
+
globalThis.fetch = vi.fn(async () =>
|
|
161
|
+
Response.json(
|
|
162
|
+
{ result: "synthetic-response" },
|
|
163
|
+
{ headers: { "x-capture": "full" } },
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
const { collector, send } = setup({ ignoreUrls: ["/capture-ingest"] });
|
|
167
|
+
try {
|
|
168
|
+
collector.start();
|
|
169
|
+
const response = await fetch("/api/deals?key=synthetic#full", {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: { "x-test": "synthetic-header" },
|
|
172
|
+
body: "synthetic-request",
|
|
173
|
+
});
|
|
174
|
+
expect(await response.json()).toEqual({ result: "synthetic-response" });
|
|
175
|
+
await fetch("/capture-ingest", { method: "POST", body: "ignore-self" });
|
|
176
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
177
|
+
const events = send.mock.calls
|
|
178
|
+
.map(([event]) => event)
|
|
179
|
+
.filter((event) => event.name === "network");
|
|
180
|
+
expect(events).toHaveLength(1);
|
|
181
|
+
expect(events[0]?.value).toMatchObject({
|
|
182
|
+
url: `${location.origin}/api/deals?key=synthetic#full`,
|
|
183
|
+
request: {
|
|
184
|
+
headers: { "x-test": "synthetic-header" },
|
|
185
|
+
body: { status: "complete", text: "synthetic-request" },
|
|
186
|
+
},
|
|
187
|
+
response: {
|
|
188
|
+
headers: { "x-capture": "full" },
|
|
189
|
+
body: { status: "complete", text: '{"result":"synthetic-response"}' },
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
} finally {
|
|
193
|
+
collector.stop();
|
|
194
|
+
globalThis.fetch = originalFetch;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("honors ignored DOM subtrees without reading their content", () => {
|
|
199
|
+
document.body.innerHTML =
|
|
200
|
+
'<section data-copilotkit-ignore><button data-copilotkit-action="secret">Private</button></section>';
|
|
201
|
+
const { collector, send } = setup();
|
|
202
|
+
collector.start();
|
|
203
|
+
document.querySelector("button")!.click();
|
|
204
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("supports capture switches while retaining initial page context", () => {
|
|
208
|
+
const { collector, send } = setup({
|
|
209
|
+
capture: {
|
|
210
|
+
clicks: false,
|
|
211
|
+
navigation: false,
|
|
212
|
+
inputs: false,
|
|
213
|
+
network: false,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
collector.start();
|
|
217
|
+
document.querySelector("button")!.click();
|
|
218
|
+
history.pushState(null, "", "/deals/42");
|
|
219
|
+
expect(send.mock.calls.map(([event]) => event.name)).toEqual(["page"]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("starts once and removes capture on stop without buffering events", async () => {
|
|
223
|
+
const pushBefore = History.prototype.pushState;
|
|
224
|
+
const { collector, send } = setup();
|
|
225
|
+
collector.emit("app.before", true);
|
|
226
|
+
collector.start();
|
|
227
|
+
collector.start();
|
|
228
|
+
collector.stop();
|
|
229
|
+
document.querySelector("button")!.click();
|
|
230
|
+
history.pushState(null, "", "/deals/42");
|
|
231
|
+
collector.emit("app.after", true);
|
|
232
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
233
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
234
|
+
expect(History.prototype.pushState).toBe(pushBefore);
|
|
235
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
236
|
+
collector.start();
|
|
237
|
+
expect(send).toHaveBeenCalledTimes(2);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it.each<JsonValue>([
|
|
241
|
+
null,
|
|
242
|
+
true,
|
|
243
|
+
42,
|
|
244
|
+
"done",
|
|
245
|
+
[1, "yes", null],
|
|
246
|
+
{ nested: { outcome: "approved" } },
|
|
247
|
+
])("preserves developer JSON values: %j", (value) => {
|
|
248
|
+
const { collector, send } = setup();
|
|
249
|
+
collector.start();
|
|
250
|
+
collector.emit("deal.approved", value);
|
|
251
|
+
expect(send.mock.calls[1]?.[0]).toEqual({
|
|
252
|
+
type: "CUSTOM",
|
|
253
|
+
name: "deal.approved",
|
|
254
|
+
timestamp: NOW,
|
|
255
|
+
value,
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("snapshots developer objects and allows shared non-cyclic values", () => {
|
|
260
|
+
const { collector, send } = setup();
|
|
261
|
+
const shared = { status: "approved" };
|
|
262
|
+
const value = { first: shared, second: shared };
|
|
263
|
+
collector.start();
|
|
264
|
+
collector.emit("deal.approved", value);
|
|
265
|
+
shared.status = "changed";
|
|
266
|
+
expect(send.mock.calls[1]?.[0].value).toEqual({
|
|
267
|
+
first: { status: "approved" },
|
|
268
|
+
second: { status: "approved" },
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("rejects non-JSON values, cycles, empty names, and reserved event names", () => {
|
|
273
|
+
const { collector, send, onError } = setup();
|
|
274
|
+
const cycle: Record<string, unknown> = {};
|
|
275
|
+
cycle.self = cycle;
|
|
276
|
+
collector.start();
|
|
277
|
+
for (const value of [
|
|
278
|
+
undefined,
|
|
279
|
+
NaN,
|
|
280
|
+
Infinity,
|
|
281
|
+
BigInt(1),
|
|
282
|
+
new Date(),
|
|
283
|
+
() => {},
|
|
284
|
+
{ absent: undefined },
|
|
285
|
+
[undefined],
|
|
286
|
+
cycle,
|
|
287
|
+
]) {
|
|
288
|
+
collector.emit("invalid", value as JsonValue);
|
|
289
|
+
}
|
|
290
|
+
collector.emit("", null);
|
|
291
|
+
collector.emit(" ", null);
|
|
292
|
+
collector.emit("click", {});
|
|
293
|
+
collector.emit("thread.linked", {});
|
|
294
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
295
|
+
expect(onError).toHaveBeenCalledTimes(13);
|
|
296
|
+
expect(
|
|
297
|
+
onError.mock.calls.every(([error]) => error.code === "INVALID_EVENT"),
|
|
298
|
+
).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("enforces the 16 KiB UTF-8 limit, including the event envelope", () => {
|
|
302
|
+
const { collector, send, onError } = setup();
|
|
303
|
+
collector.start();
|
|
304
|
+
const empty = {
|
|
305
|
+
type: "CUSTOM",
|
|
306
|
+
name: "app.value",
|
|
307
|
+
timestamp: NOW,
|
|
308
|
+
value: "",
|
|
309
|
+
};
|
|
310
|
+
const available =
|
|
311
|
+
MAX_TRAJECTORY_EVENT_BYTES -
|
|
312
|
+
new TextEncoder().encode(JSON.stringify(empty)).byteLength;
|
|
313
|
+
collector.emit("app.value", "a".repeat(available));
|
|
314
|
+
collector.emit("app.value", "a".repeat(available + 1));
|
|
315
|
+
collector.emit("app.value", "😀".repeat(Math.floor(available / 2)));
|
|
316
|
+
expect(send).toHaveBeenCalledTimes(2);
|
|
317
|
+
expect(onError).toHaveBeenCalledTimes(2);
|
|
318
|
+
expect(onError).toHaveBeenCalledWith(
|
|
319
|
+
expect.objectContaining({ code: "EVENT_TOO_LARGE" }),
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("rejects NUL characters and event names the Gateway cannot store", () => {
|
|
324
|
+
const { collector, send, onError } = setup();
|
|
325
|
+
collector.start();
|
|
326
|
+
collector.emit("app.value", "null\0character");
|
|
327
|
+
collector.emit("app.value", { "key\0name": "value" });
|
|
328
|
+
collector.emit("app\0name", true);
|
|
329
|
+
collector.emit("a".repeat(201), true);
|
|
330
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
331
|
+
expect(onError).toHaveBeenCalledTimes(4);
|
|
332
|
+
expect(
|
|
333
|
+
onError.mock.calls.every(([error]) => error.code === "INVALID_EVENT"),
|
|
334
|
+
).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("runs filtering before final validation and sending", () => {
|
|
338
|
+
const { collector, send, onError } = setup({
|
|
339
|
+
beforeSend: (event) =>
|
|
340
|
+
event.name === "page"
|
|
341
|
+
? null
|
|
342
|
+
: { ...event, value: undefined as unknown as JsonValue },
|
|
343
|
+
});
|
|
344
|
+
collector.start();
|
|
345
|
+
collector.emit("app.event", true);
|
|
346
|
+
expect(send).not.toHaveBeenCalled();
|
|
347
|
+
expect(onError).toHaveBeenCalledWith(
|
|
348
|
+
expect.objectContaining({ code: "INVALID_EVENT" }),
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("contains callback errors and does not retry failed sends", async () => {
|
|
353
|
+
const { collector, send } = setup({
|
|
354
|
+
onError: () => {
|
|
355
|
+
throw new Error("diagnostic callback");
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
send.mockImplementation(() => {
|
|
359
|
+
throw new Error("failed send");
|
|
360
|
+
});
|
|
361
|
+
expect(() => collector.start()).not.toThrow();
|
|
362
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
363
|
+
collector.stop();
|
|
364
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
365
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { builtinEnvironments } from "vitest/environments";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
...builtinEnvironments.jsdom,
|
|
5
|
+
async setup(global, options) {
|
|
6
|
+
// jsdom has no fetch/Response implementation. Keep FormData in the same
|
|
7
|
+
// realm as Node's Response: Node 24 no longer serializes jsdom FormData.
|
|
8
|
+
const formData = global.FormData;
|
|
9
|
+
const environment = await builtinEnvironments.jsdom.setup(global, options);
|
|
10
|
+
const descriptor = Object.getOwnPropertyDescriptor(global, "FormData");
|
|
11
|
+
Object.defineProperty(global, "FormData", {
|
|
12
|
+
configurable: true,
|
|
13
|
+
writable: true,
|
|
14
|
+
value: formData,
|
|
15
|
+
});
|
|
16
|
+
return {
|
|
17
|
+
teardown() {
|
|
18
|
+
Object.defineProperty(global, "FormData", descriptor);
|
|
19
|
+
environment.teardown(global);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
};
|
package/src/clicks.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createRedactor, isCredentialKey } from "./redact";
|
|
2
|
+
import type { Redactor } from "./redact";
|
|
3
|
+
import { REDACTED } from "./types";
|
|
4
|
+
import type { ClickTarget, Emit, EnrichFn } from "./types";
|
|
5
|
+
|
|
6
|
+
const INTERACTIVE =
|
|
7
|
+
'button, a[href], input, select, textarea, label, summary, [role], [data-copilotkit-action], [contenteditable]:not([contenteditable="false"])';
|
|
8
|
+
const RECOVERY_DISTANCE_PX = 5;
|
|
9
|
+
// `[role]` also matches containers such as role="main"; cap their text so one
|
|
10
|
+
// click cannot exceed the 16 KiB event limit and be dropped.
|
|
11
|
+
const MAX_TEXT_LENGTH = 1000;
|
|
12
|
+
|
|
13
|
+
/** `closest()` that continues through shadow roots to their hosts. */
|
|
14
|
+
function closestAcrossShadow(el: Element, selector: string) {
|
|
15
|
+
let current: Element | null = el;
|
|
16
|
+
while (current !== null) {
|
|
17
|
+
const match = current.closest(selector);
|
|
18
|
+
if (match !== null) return match;
|
|
19
|
+
const root = current.getRootNode();
|
|
20
|
+
current = root instanceof ShadowRoot ? root.host : null;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Described {
|
|
26
|
+
element: Element;
|
|
27
|
+
target: Omit<ClickTarget, "input">;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface PointerOrigin {
|
|
31
|
+
described: Described | null;
|
|
32
|
+
x: number;
|
|
33
|
+
y: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Finds the nearest interactive element and retains its text, attributes, and value.
|
|
38
|
+
* The explicit `data-copilotkit-ignore` opt-out still applies.
|
|
39
|
+
*/
|
|
40
|
+
export function describeTarget(el: Element, redact: Redactor) {
|
|
41
|
+
if (closestAcrossShadow(el, "[data-copilotkit-ignore]") !== null) return null;
|
|
42
|
+
const element = el.closest(INTERACTIVE) ?? el;
|
|
43
|
+
// React and server markup can mirror the password into the value attribute.
|
|
44
|
+
const password = redact.isPassword(element);
|
|
45
|
+
const described: Described = {
|
|
46
|
+
element,
|
|
47
|
+
target: {
|
|
48
|
+
tag: element.localName,
|
|
49
|
+
role: element.getAttribute("role"),
|
|
50
|
+
action: element.getAttribute("data-copilotkit-action"),
|
|
51
|
+
text: (element.textContent ?? "").slice(0, MAX_TEXT_LENGTH),
|
|
52
|
+
attributes: Object.fromEntries(
|
|
53
|
+
Array.from(element.attributes, ({ name, value }) => [
|
|
54
|
+
name,
|
|
55
|
+
// Credential-named attributes (`data-api-key`) are redacted whole;
|
|
56
|
+
// URL-valued ones (`href`, `action`, …) can carry credentials too.
|
|
57
|
+
(password && name === "value") ||
|
|
58
|
+
isCredentialKey(name.replace(/^data-/, ""))
|
|
59
|
+
? REDACTED
|
|
60
|
+
: redact.url(value),
|
|
61
|
+
]),
|
|
62
|
+
),
|
|
63
|
+
...readControlValue(element, redact),
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
return described;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Snapshot live control properties; HTML attributes do not reflect edited values. */
|
|
70
|
+
export function readControlValue(element: Element, redact: Redactor) {
|
|
71
|
+
if (element instanceof HTMLInputElement) {
|
|
72
|
+
return {
|
|
73
|
+
value: redact.isPassword(element) ? REDACTED : element.value,
|
|
74
|
+
...(["checkbox", "radio"].includes(element.type)
|
|
75
|
+
? { checked: element.checked }
|
|
76
|
+
: {}),
|
|
77
|
+
...(element.type === "file"
|
|
78
|
+
? {
|
|
79
|
+
files: Array.from(element.files ?? [], (file) => ({
|
|
80
|
+
name: file.name,
|
|
81
|
+
type: file.type,
|
|
82
|
+
size: file.size,
|
|
83
|
+
lastModified: file.lastModified,
|
|
84
|
+
})),
|
|
85
|
+
}
|
|
86
|
+
: {}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (element instanceof HTMLSelectElement) {
|
|
90
|
+
return {
|
|
91
|
+
value: element.value,
|
|
92
|
+
selectedValues: Array.from(
|
|
93
|
+
element.selectedOptions,
|
|
94
|
+
(option) => option.value,
|
|
95
|
+
),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (element instanceof HTMLTextAreaElement) return { value: element.value };
|
|
99
|
+
if (element.matches('[contenteditable]:not([contenteditable="false"])')) {
|
|
100
|
+
return { value: element.textContent ?? "" };
|
|
101
|
+
}
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function eventElement(event: Event) {
|
|
106
|
+
const [first] = event.composedPath();
|
|
107
|
+
return first instanceof Element ? first : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isRootOrDetached(el: Element) {
|
|
111
|
+
return el.localName === "body" || el.localName === "html" || !el.isConnected;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isNear(origin: PointerOrigin, event: MouseEvent) {
|
|
115
|
+
return (
|
|
116
|
+
Math.abs(origin.x - event.clientX) <= RECOVERY_DISTANCE_PX &&
|
|
117
|
+
Math.abs(origin.y - event.clientY) <= RECOVERY_DISTANCE_PX
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Listens for clicks in the capture phase on `window`, so handlers that stop
|
|
123
|
+
* propagation do not hide them. The target is described at `pointerdown`,
|
|
124
|
+
* because a re-render before `click` can move the click to `<body>`.
|
|
125
|
+
*/
|
|
126
|
+
export function installClickCapture(params: {
|
|
127
|
+
emit: Emit;
|
|
128
|
+
getRoute: () => string;
|
|
129
|
+
enrich?: EnrichFn;
|
|
130
|
+
// ponytail: test seam only. jsdom cannot create trusted events; the collector never passes this.
|
|
131
|
+
isTrusted?: (event: Event) => boolean;
|
|
132
|
+
redact?: Redactor;
|
|
133
|
+
}) {
|
|
134
|
+
const {
|
|
135
|
+
emit,
|
|
136
|
+
getRoute,
|
|
137
|
+
enrich,
|
|
138
|
+
isTrusted = (event) => event.isTrusted,
|
|
139
|
+
redact = createRedactor(),
|
|
140
|
+
} = params;
|
|
141
|
+
let origin: PointerOrigin | null = null;
|
|
142
|
+
|
|
143
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
144
|
+
try {
|
|
145
|
+
if (event.isPrimary === false) return;
|
|
146
|
+
const el = eventElement(event);
|
|
147
|
+
origin = {
|
|
148
|
+
described: el === null ? null : describeTarget(el, redact),
|
|
149
|
+
x: event.clientX,
|
|
150
|
+
y: event.clientY,
|
|
151
|
+
};
|
|
152
|
+
} catch {
|
|
153
|
+
origin = null;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const onClick = (event: MouseEvent) => {
|
|
158
|
+
const pointerOrigin = origin;
|
|
159
|
+
origin = null;
|
|
160
|
+
try {
|
|
161
|
+
if (!isTrusted(event)) return;
|
|
162
|
+
const el = eventElement(event);
|
|
163
|
+
if (el === null) return;
|
|
164
|
+
const canRecover =
|
|
165
|
+
pointerOrigin !== null &&
|
|
166
|
+
isRootOrDetached(el) &&
|
|
167
|
+
isNear(pointerOrigin, event);
|
|
168
|
+
const described = canRecover
|
|
169
|
+
? pointerOrigin.described
|
|
170
|
+
: describeTarget(el, redact);
|
|
171
|
+
if (described === null) return;
|
|
172
|
+
const input = event.detail === 0 ? "keyboard" : "pointer";
|
|
173
|
+
emit("click", {
|
|
174
|
+
target: { ...described.target, input },
|
|
175
|
+
route: getRoute(),
|
|
176
|
+
url: redact.url(location.href),
|
|
177
|
+
...enrich?.(described.element),
|
|
178
|
+
});
|
|
179
|
+
} catch {
|
|
180
|
+
// Capture must never break the host app's click.
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
window.addEventListener("pointerdown", onPointerDown, {
|
|
185
|
+
capture: true,
|
|
186
|
+
passive: true,
|
|
187
|
+
});
|
|
188
|
+
window.addEventListener("click", onClick, { capture: true, passive: true });
|
|
189
|
+
return () => {
|
|
190
|
+
window.removeEventListener("pointerdown", onPointerDown, { capture: true });
|
|
191
|
+
window.removeEventListener("click", onClick, { capture: true });
|
|
192
|
+
};
|
|
193
|
+
}
|