@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,307 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createCollector } from "../collector";
|
|
3
|
+
import type { CollectorOptions, LearningBatch, LearningSink } from "../types";
|
|
4
|
+
|
|
5
|
+
const NOW = 1_790_000_000_000;
|
|
6
|
+
const realFetch = globalThis.fetch;
|
|
7
|
+
const fakeFetch = vi.fn<typeof fetch>(
|
|
8
|
+
async () => new Response(null, { status: 204 }),
|
|
9
|
+
);
|
|
10
|
+
let stopCurrent: (() => void) | undefined;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.useFakeTimers();
|
|
14
|
+
vi.setSystemTime(NOW);
|
|
15
|
+
globalThis.fetch = fakeFetch;
|
|
16
|
+
history.replaceState(null, "", "/learning");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
stopCurrent?.();
|
|
21
|
+
stopCurrent = undefined;
|
|
22
|
+
vi.useRealTimers();
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
vi.unstubAllGlobals();
|
|
25
|
+
globalThis.fetch = realFetch;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function setup(overrides: Partial<CollectorOptions> = {}) {
|
|
29
|
+
const batches: LearningBatch[] = [];
|
|
30
|
+
const sink: LearningSink = (batch) => {
|
|
31
|
+
batches.push(batch);
|
|
32
|
+
};
|
|
33
|
+
const collector = createCollector({
|
|
34
|
+
sink,
|
|
35
|
+
routes: ["/learning/deals/:id"],
|
|
36
|
+
...overrides,
|
|
37
|
+
});
|
|
38
|
+
stopCurrent = () => collector.stop();
|
|
39
|
+
return { collector, batches };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function names(batches: LearningBatch[]) {
|
|
43
|
+
return batches.flatMap((batch) => batch.events.map((event) => event.name));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("createCollector", () => {
|
|
47
|
+
it("installs nothing before start", () => {
|
|
48
|
+
const { batches } = setup();
|
|
49
|
+
|
|
50
|
+
history.pushState(null, "", "/learning/deals/1");
|
|
51
|
+
vi.advanceTimersByTime(5000);
|
|
52
|
+
|
|
53
|
+
expect(globalThis.fetch).toBe(fakeFetch);
|
|
54
|
+
expect(batches).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("sends a page event, then batches every 2 seconds", () => {
|
|
58
|
+
const { collector, batches } = setup();
|
|
59
|
+
|
|
60
|
+
collector.start({ trajectoryId: "traj-1", learningContainerIds: ["c-1"] });
|
|
61
|
+
history.pushState(null, "", "/learning/deals/1");
|
|
62
|
+
vi.advanceTimersByTime(2000);
|
|
63
|
+
|
|
64
|
+
expect(batches).toEqual([
|
|
65
|
+
{
|
|
66
|
+
trajectoryId: "traj-1",
|
|
67
|
+
learningContainerIds: ["c-1"],
|
|
68
|
+
dropped: 0,
|
|
69
|
+
events: [
|
|
70
|
+
{
|
|
71
|
+
type: "CUSTOM",
|
|
72
|
+
name: "page",
|
|
73
|
+
timestamp: NOW,
|
|
74
|
+
value: {
|
|
75
|
+
route: "/learning",
|
|
76
|
+
url: `${location.origin}/learning`,
|
|
77
|
+
title: document.title,
|
|
78
|
+
referrer: document.referrer,
|
|
79
|
+
seq: 1,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
type: "CUSTOM",
|
|
84
|
+
name: "navigation",
|
|
85
|
+
timestamp: NOW,
|
|
86
|
+
value: {
|
|
87
|
+
from: `${location.origin}/learning`,
|
|
88
|
+
to: `${location.origin}/learning/deals/1`,
|
|
89
|
+
navigationType: "push",
|
|
90
|
+
seq: 2,
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("flushes as soon as 50 events are queued", () => {
|
|
99
|
+
const { collector, batches } = setup();
|
|
100
|
+
collector.start({ trajectoryId: "traj-1" });
|
|
101
|
+
|
|
102
|
+
for (let index = 0; index < 49; index += 1)
|
|
103
|
+
collector.emit("deal.viewed", { index });
|
|
104
|
+
|
|
105
|
+
expect(batches).toHaveLength(1);
|
|
106
|
+
expect(batches[0]?.events).toHaveLength(50);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("ignores a second start with the same id and warns for another id", () => {
|
|
110
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
111
|
+
const { collector, batches } = setup();
|
|
112
|
+
|
|
113
|
+
collector.start({ trajectoryId: "traj-1" });
|
|
114
|
+
collector.start({ trajectoryId: "traj-1" });
|
|
115
|
+
collector.start({ trajectoryId: "traj-2" });
|
|
116
|
+
vi.advanceTimersByTime(2000);
|
|
117
|
+
|
|
118
|
+
expect(collector.trajectoryId).toBe("traj-1");
|
|
119
|
+
expect(names(batches)).toEqual(["page"]);
|
|
120
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("starts without the password watcher when the DOM is partial", () => {
|
|
124
|
+
vi.spyOn(document, "querySelectorAll").mockImplementation(() => {
|
|
125
|
+
throw new Error("no query support");
|
|
126
|
+
});
|
|
127
|
+
vi.stubGlobal(
|
|
128
|
+
"MutationObserver",
|
|
129
|
+
class {
|
|
130
|
+
observe() {
|
|
131
|
+
throw new Error("no observer support");
|
|
132
|
+
}
|
|
133
|
+
disconnect() {}
|
|
134
|
+
takeRecords() {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
const { collector } = setup();
|
|
140
|
+
|
|
141
|
+
expect(() => collector.start({ trajectoryId: "traj-1" })).not.toThrow();
|
|
142
|
+
expect(collector.trajectoryId).toBe("traj-1");
|
|
143
|
+
expect(() => collector.stop()).not.toThrow();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("installs hooks once across start, stop, start (StrictMode)", () => {
|
|
147
|
+
const { collector, batches } = setup();
|
|
148
|
+
|
|
149
|
+
collector.start({ trajectoryId: "traj-1" });
|
|
150
|
+
collector.stop();
|
|
151
|
+
collector.start({ trajectoryId: "traj-1" });
|
|
152
|
+
history.pushState(null, "", "/learning/deals/9");
|
|
153
|
+
vi.advanceTimersByTime(2000);
|
|
154
|
+
|
|
155
|
+
expect(names(batches)).toEqual(["page", "page", "navigation"]);
|
|
156
|
+
expect(
|
|
157
|
+
batches.flatMap((batch) => batch.events.map((event) => event.value.seq)),
|
|
158
|
+
).toEqual([1, 2, 3]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("sends the final outcome once and removes capture hooks on stop", () => {
|
|
162
|
+
const { collector, batches } = setup();
|
|
163
|
+
collector.start({ trajectoryId: "traj-1" });
|
|
164
|
+
collector.emit("deal.approved", { dealId: "deal-1" });
|
|
165
|
+
|
|
166
|
+
collector.stop();
|
|
167
|
+
collector.stop();
|
|
168
|
+
collector.emit("deal.viewed", {});
|
|
169
|
+
history.pushState(null, "", "/learning/deals/1");
|
|
170
|
+
vi.advanceTimersByTime(5000);
|
|
171
|
+
|
|
172
|
+
expect(batches).toHaveLength(1);
|
|
173
|
+
expect(batches[0]?.trajectoryId).toBe("traj-1");
|
|
174
|
+
expect(names(batches)).toEqual(["page", "deal.approved"]);
|
|
175
|
+
expect(batches[0]?.events[1]?.value).toEqual({ dealId: "deal-1", seq: 2 });
|
|
176
|
+
expect(collector.trajectoryId).toBeNull();
|
|
177
|
+
expect(globalThis.fetch).toBe(fakeFetch);
|
|
178
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it.each(["throw", "reject"])(
|
|
182
|
+
"does not retry or carry failed final events into the next trajectory (%s)",
|
|
183
|
+
async (failure) => {
|
|
184
|
+
const sink = vi.fn<LearningSink>(() => {
|
|
185
|
+
if (failure === "throw") throw new Error("offline");
|
|
186
|
+
return Promise.reject(new Error("offline"));
|
|
187
|
+
});
|
|
188
|
+
const { collector } = setup({ sink });
|
|
189
|
+
collector.start({ trajectoryId: "traj-1" });
|
|
190
|
+
collector.emit("deal.approved", {});
|
|
191
|
+
|
|
192
|
+
expect(() => collector.stop()).not.toThrow();
|
|
193
|
+
expect(collector.trajectoryId).toBeNull();
|
|
194
|
+
sink.mockImplementation(() => {});
|
|
195
|
+
collector.start({ trajectoryId: "traj-2" });
|
|
196
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
197
|
+
|
|
198
|
+
expect(sink).toHaveBeenCalledTimes(2);
|
|
199
|
+
expect(sink.mock.calls[1]?.[0]).toMatchObject({
|
|
200
|
+
trajectoryId: "traj-2",
|
|
201
|
+
dropped: 0,
|
|
202
|
+
events: [{ name: "page", value: { seq: 3 } }],
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
it("retains raw paths and full URLs in captured activity", async () => {
|
|
208
|
+
history.replaceState(null, "", "/reset/eyJhbGc.eyJzdWIi.sig");
|
|
209
|
+
const { collector, batches } = setup();
|
|
210
|
+
collector.start({ trajectoryId: "traj-1" });
|
|
211
|
+
history.pushState(null, "", "/users/jane@x.com?q=hello#details");
|
|
212
|
+
await fetch("/search/private%20search");
|
|
213
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
214
|
+
|
|
215
|
+
collector.stop();
|
|
216
|
+
|
|
217
|
+
expect(batches[0]?.events).toEqual(
|
|
218
|
+
expect.arrayContaining([
|
|
219
|
+
expect.objectContaining({
|
|
220
|
+
name: "page",
|
|
221
|
+
value: expect.objectContaining({
|
|
222
|
+
route: "/reset/eyJhbGc.eyJzdWIi.sig",
|
|
223
|
+
}),
|
|
224
|
+
}),
|
|
225
|
+
expect.objectContaining({
|
|
226
|
+
name: "navigation",
|
|
227
|
+
value: expect.objectContaining({
|
|
228
|
+
from: `${location.origin}/reset/eyJhbGc.eyJzdWIi.sig`,
|
|
229
|
+
to: `${location.origin}/users/jane@x.com?q=hello#details`,
|
|
230
|
+
}),
|
|
231
|
+
}),
|
|
232
|
+
expect.objectContaining({
|
|
233
|
+
name: "network",
|
|
234
|
+
value: expect.objectContaining({ route: "/search/private%20search" }),
|
|
235
|
+
}),
|
|
236
|
+
]),
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("lets beforeSend redact or drop events", () => {
|
|
241
|
+
const { collector, batches } = setup({
|
|
242
|
+
beforeSend: (event) =>
|
|
243
|
+
event.name === "deal.secret"
|
|
244
|
+
? null
|
|
245
|
+
: { ...event, value: { ...event.value, route: "redacted" } },
|
|
246
|
+
});
|
|
247
|
+
collector.start({ trajectoryId: "traj-1" });
|
|
248
|
+
collector.emit("deal.secret", {});
|
|
249
|
+
|
|
250
|
+
vi.advanceTimersByTime(2000);
|
|
251
|
+
|
|
252
|
+
expect(
|
|
253
|
+
batches[0]?.events.map((event) => [event.name, event.value.route]),
|
|
254
|
+
).toEqual([["page", "redacted"]]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("rejects built-in names from emit but accepts them from ɵemit", () => {
|
|
258
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
259
|
+
const { collector, batches } = setup();
|
|
260
|
+
collector.start({ trajectoryId: "traj-1" });
|
|
261
|
+
|
|
262
|
+
collector.emit("click", { fake: true });
|
|
263
|
+
collector.ɵemit("thread.linked", { threadId: "t-1" });
|
|
264
|
+
vi.advanceTimersByTime(2000);
|
|
265
|
+
|
|
266
|
+
expect(names(batches)).toEqual(["page", "thread.linked"]);
|
|
267
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("survives a failing sink and reports the loss in the next batch", async () => {
|
|
271
|
+
const batches: LearningBatch[] = [];
|
|
272
|
+
let calls = 0;
|
|
273
|
+
const sink: LearningSink = async (batch) => {
|
|
274
|
+
calls += 1;
|
|
275
|
+
if (calls === 1) throw new Error("offline");
|
|
276
|
+
batches.push(batch);
|
|
277
|
+
};
|
|
278
|
+
const { collector } = setup({ sink });
|
|
279
|
+
collector.start({ trajectoryId: "traj-1" });
|
|
280
|
+
collector.emit("deal.viewed", {});
|
|
281
|
+
|
|
282
|
+
vi.advanceTimersByTime(2000);
|
|
283
|
+
await vi.runOnlyPendingTimersAsync();
|
|
284
|
+
|
|
285
|
+
expect(batches).toEqual([
|
|
286
|
+
{
|
|
287
|
+
trajectoryId: "traj-1",
|
|
288
|
+
learningContainerIds: undefined,
|
|
289
|
+
dropped: 2,
|
|
290
|
+
events: [],
|
|
291
|
+
},
|
|
292
|
+
]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("flushes with beacon on pagehide", () => {
|
|
296
|
+
const sends: unknown[] = [];
|
|
297
|
+
const sink: LearningSink = (_batch, options) => {
|
|
298
|
+
sends.push(options);
|
|
299
|
+
};
|
|
300
|
+
const { collector } = setup({ sink });
|
|
301
|
+
collector.start({ trajectoryId: "traj-1" });
|
|
302
|
+
|
|
303
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
304
|
+
|
|
305
|
+
expect(sends).toEqual([{ beacon: true }]);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { httpSink } from "../http-sink";
|
|
3
|
+
import type { LearningBatch } from "../types";
|
|
4
|
+
|
|
5
|
+
const realFetch = globalThis.fetch;
|
|
6
|
+
const realSendBeacon = navigator.sendBeacon;
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
globalThis.fetch = realFetch;
|
|
10
|
+
navigator.sendBeacon = realSendBeacon;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function createBatch(): LearningBatch {
|
|
14
|
+
return {
|
|
15
|
+
trajectoryId: "traj-1",
|
|
16
|
+
dropped: 0,
|
|
17
|
+
events: [
|
|
18
|
+
{
|
|
19
|
+
type: "CUSTOM",
|
|
20
|
+
name: "page",
|
|
21
|
+
timestamp: 1,
|
|
22
|
+
value: { seq: 1, route: "/" },
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("httpSink", () => {
|
|
29
|
+
it("POSTs the batch as JSON with keepalive and the extra headers", async () => {
|
|
30
|
+
const fetchMock = vi.fn<typeof fetch>(
|
|
31
|
+
async () => new Response(null, { status: 204 }),
|
|
32
|
+
);
|
|
33
|
+
globalThis.fetch = fetchMock;
|
|
34
|
+
|
|
35
|
+
await httpSink("/api/learning-events", {
|
|
36
|
+
headers: { "x-project": "demo" },
|
|
37
|
+
})(createBatch());
|
|
38
|
+
|
|
39
|
+
expect(fetchMock.mock.calls[0]?.[1]).toEqual({
|
|
40
|
+
method: "POST",
|
|
41
|
+
keepalive: true,
|
|
42
|
+
headers: { "content-type": "application/json", "x-project": "demo" },
|
|
43
|
+
body: JSON.stringify(createBatch()),
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects when the sink responds with an error status", async () => {
|
|
48
|
+
globalThis.fetch = vi.fn<typeof fetch>(
|
|
49
|
+
async () => new Response(null, { status: 500 }),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
await expect(
|
|
53
|
+
httpSink("/api/learning-events")(createBatch()),
|
|
54
|
+
).rejects.toThrow("Sink responded with 500");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("uses sendBeacon when the page is going away", async () => {
|
|
58
|
+
const beacon = vi.fn<typeof navigator.sendBeacon>(() => true);
|
|
59
|
+
navigator.sendBeacon = beacon;
|
|
60
|
+
const fetchMock = vi.fn<typeof fetch>();
|
|
61
|
+
globalThis.fetch = fetchMock;
|
|
62
|
+
|
|
63
|
+
await httpSink("/api/learning-events")(createBatch(), { beacon: true });
|
|
64
|
+
|
|
65
|
+
expect(beacon.mock.calls[0]?.[0]).toBe("/api/learning-events");
|
|
66
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("exposes its url so the collector never captures its own requests", () => {
|
|
70
|
+
expect(httpSink("/api/learning-events").url).toBe("/api/learning-events");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { installInputCapture } from "../inputs";
|
|
3
|
+
|
|
4
|
+
let uninstall: (() => void) | undefined;
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
uninstall?.();
|
|
7
|
+
document.body.innerHTML = "";
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("input capture", () => {
|
|
11
|
+
it("captures edited text, checkbox state, and selected values, and redacts passwords", () => {
|
|
12
|
+
document.body.innerHTML = `<input id="name"><input id="password" type="password" value="secret"><input id="check" type="checkbox"><textarea id="notes"></textarea><select id="choice" multiple><option value="a" selected>A</option><option value="b" selected>B</option></select><div id="editor" contenteditable="true">Rich text</div>`;
|
|
13
|
+
const events: Record<string, unknown>[] = [];
|
|
14
|
+
uninstall = installInputCapture({
|
|
15
|
+
emit: (name, value) => events.push({ name, ...value }),
|
|
16
|
+
isTrusted: () => true,
|
|
17
|
+
});
|
|
18
|
+
for (const id of [
|
|
19
|
+
"name",
|
|
20
|
+
"password",
|
|
21
|
+
"check",
|
|
22
|
+
"notes",
|
|
23
|
+
"choice",
|
|
24
|
+
"editor",
|
|
25
|
+
]) {
|
|
26
|
+
const element = document.getElementById(id)!;
|
|
27
|
+
if (
|
|
28
|
+
element instanceof HTMLInputElement ||
|
|
29
|
+
element instanceof HTMLTextAreaElement
|
|
30
|
+
)
|
|
31
|
+
element.value = `synthetic-${id}`;
|
|
32
|
+
if (element instanceof HTMLInputElement && element.type === "checkbox")
|
|
33
|
+
element.checked = true;
|
|
34
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
35
|
+
}
|
|
36
|
+
expect(events).toHaveLength(6);
|
|
37
|
+
expect(events[0]).toMatchObject({
|
|
38
|
+
name: "input",
|
|
39
|
+
eventType: "input",
|
|
40
|
+
url: location.href,
|
|
41
|
+
target: { value: "synthetic-name", attributes: { id: "name" } },
|
|
42
|
+
});
|
|
43
|
+
expect(events[1]).toMatchObject({
|
|
44
|
+
target: { value: "[redacted]", attributes: { value: "[redacted]" } },
|
|
45
|
+
});
|
|
46
|
+
expect(JSON.stringify(events[1])).not.toContain("secret");
|
|
47
|
+
expect(events[2]).toMatchObject({ target: { checked: true } });
|
|
48
|
+
expect(events[3]).toMatchObject({ target: { value: "synthetic-notes" } });
|
|
49
|
+
expect(events[4]).toMatchObject({ target: { selectedValues: ["a", "b"] } });
|
|
50
|
+
expect(events[5]).toMatchObject({ target: { value: "Rich text" } });
|
|
51
|
+
uninstall();
|
|
52
|
+
document
|
|
53
|
+
.getElementById("name")!
|
|
54
|
+
.dispatchEvent(new Event("change", { bubbles: true }));
|
|
55
|
+
expect(events).toHaveLength(6);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("ignores synthetic edits by default and honors explicit opt-outs", () => {
|
|
59
|
+
document.body.innerHTML = `<input id="field" value="synthetic" data-copilotkit-ignore>`;
|
|
60
|
+
// Assert externally because capture deliberately catches observer exceptions.
|
|
61
|
+
const events: unknown[] = [];
|
|
62
|
+
uninstall = installInputCapture({ emit: (...args) => events.push(args) });
|
|
63
|
+
document
|
|
64
|
+
.getElementById("field")!
|
|
65
|
+
.dispatchEvent(new Event("input", { bubbles: true }));
|
|
66
|
+
expect(events).toEqual([]);
|
|
67
|
+
uninstall();
|
|
68
|
+
uninstall = installInputCapture({
|
|
69
|
+
emit: (...args) => events.push(args),
|
|
70
|
+
isTrusted: () => true,
|
|
71
|
+
});
|
|
72
|
+
document
|
|
73
|
+
.getElementById("field")!
|
|
74
|
+
.dispatchEvent(new Event("change", { bubbles: true }));
|
|
75
|
+
expect(events).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { installNavigationCapture } from "../navigation";
|
|
3
|
+
|
|
4
|
+
interface Captured {
|
|
5
|
+
name: string;
|
|
6
|
+
value: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const originalPushState = History.prototype.pushState;
|
|
10
|
+
const originalReplaceState = History.prototype.replaceState;
|
|
11
|
+
let uninstall: (() => void) | undefined;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
originalReplaceState.call(history, null, "", "/learning");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
uninstall?.();
|
|
19
|
+
uninstall = undefined;
|
|
20
|
+
Reflect.deleteProperty(history, "pushState");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function setup() {
|
|
24
|
+
const events: Captured[] = [];
|
|
25
|
+
uninstall = installNavigationCapture({
|
|
26
|
+
emit: (name, value) => events.push({ name, value }),
|
|
27
|
+
routes: ["/learning/deals/:id"],
|
|
28
|
+
});
|
|
29
|
+
return events;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("installNavigationCapture (history fallback)", () => {
|
|
33
|
+
it("emits push and replace when the full URL changes", () => {
|
|
34
|
+
const events = setup();
|
|
35
|
+
|
|
36
|
+
history.pushState(null, "", "/learning/deals/42");
|
|
37
|
+
history.replaceState(null, "", "/learning/events");
|
|
38
|
+
|
|
39
|
+
expect(events).toEqual([
|
|
40
|
+
{
|
|
41
|
+
name: "navigation",
|
|
42
|
+
value: {
|
|
43
|
+
from: `${location.origin}/learning`,
|
|
44
|
+
to: `${location.origin}/learning/deals/42`,
|
|
45
|
+
navigationType: "push",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "navigation",
|
|
50
|
+
value: {
|
|
51
|
+
from: `${location.origin}/learning/deals/42`,
|
|
52
|
+
to: `${location.origin}/learning/events`,
|
|
53
|
+
navigationType: "replace",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("records changes within a route, including query strings and fragments", () => {
|
|
60
|
+
const events = setup();
|
|
61
|
+
|
|
62
|
+
history.pushState(null, "", "/learning/deals/1");
|
|
63
|
+
history.pushState(null, "", "/learning/deals/2?tab=notes#top");
|
|
64
|
+
|
|
65
|
+
expect(events).toHaveLength(2);
|
|
66
|
+
expect(events[1]?.value.to).toBe(
|
|
67
|
+
`${location.origin}/learning/deals/2?tab=notes#top`,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("still fires when a router wrapped the instance method before install", () => {
|
|
72
|
+
const routerPushState = history.pushState;
|
|
73
|
+
history.pushState = function (...args) {
|
|
74
|
+
return routerPushState.apply(this, args);
|
|
75
|
+
};
|
|
76
|
+
const events = setup();
|
|
77
|
+
|
|
78
|
+
history.pushState(null, "", "/learning/deals/7");
|
|
79
|
+
|
|
80
|
+
expect(events).toEqual([
|
|
81
|
+
{
|
|
82
|
+
name: "navigation",
|
|
83
|
+
value: {
|
|
84
|
+
from: `${location.origin}/learning`,
|
|
85
|
+
to: `${location.origin}/learning/deals/7`,
|
|
86
|
+
navigationType: "push",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("emits traverse on back navigation", async () => {
|
|
93
|
+
const events = setup();
|
|
94
|
+
history.pushState(null, "", "/learning/deals/3");
|
|
95
|
+
const popped = new Promise((resolve) =>
|
|
96
|
+
window.addEventListener("popstate", resolve, { once: true }),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
history.back();
|
|
100
|
+
await popped;
|
|
101
|
+
|
|
102
|
+
expect(events.at(-1)?.value).toEqual({
|
|
103
|
+
from: `${location.origin}/learning/deals/3`,
|
|
104
|
+
to: `${location.origin}/learning`,
|
|
105
|
+
navigationType: "traverse",
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("restores the original history methods on uninstall", () => {
|
|
110
|
+
setup();
|
|
111
|
+
uninstall?.();
|
|
112
|
+
uninstall = undefined;
|
|
113
|
+
|
|
114
|
+
expect(History.prototype.pushState).toBe(originalPushState);
|
|
115
|
+
expect(History.prototype.replaceState).toBe(originalReplaceState);
|
|
116
|
+
});
|
|
117
|
+
});
|