@copilotkit/learning 0.0.1 → 1.80.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
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createCollector } from "../collector";
|
|
3
|
+
import type * as InputCapture from "../inputs";
|
|
4
|
+
|
|
5
|
+
// jsdom cannot create trusted input; retain the real capture and lifecycle.
|
|
6
|
+
vi.mock("../inputs", async (importOriginal) => {
|
|
7
|
+
const actual = await importOriginal<typeof InputCapture>();
|
|
8
|
+
return {
|
|
9
|
+
...actual,
|
|
10
|
+
installInputCapture: (
|
|
11
|
+
params: Parameters<typeof actual.installInputCapture>[0],
|
|
12
|
+
) => actual.installInputCapture({ ...params, isTrusted: () => true }),
|
|
13
|
+
};
|
|
14
|
+
});
|
|
3
15
|
import type { CollectorOptions, LearningBatch, LearningSink } from "../types";
|
|
4
16
|
|
|
5
17
|
const NOW = 1_790_000_000_000;
|
|
@@ -19,6 +31,7 @@ beforeEach(() => {
|
|
|
19
31
|
afterEach(() => {
|
|
20
32
|
stopCurrent?.();
|
|
21
33
|
stopCurrent = undefined;
|
|
34
|
+
document.body.innerHTML = "";
|
|
22
35
|
vi.useRealTimers();
|
|
23
36
|
vi.restoreAllMocks();
|
|
24
37
|
vi.unstubAllGlobals();
|
|
@@ -305,3 +318,142 @@ describe("createCollector", () => {
|
|
|
305
318
|
expect(sends).toEqual([{ beacon: true }]);
|
|
306
319
|
});
|
|
307
320
|
});
|
|
321
|
+
|
|
322
|
+
it.each(["emit", "stop", "pagehide"])(
|
|
323
|
+
"the batched collector flushes pending input before %s",
|
|
324
|
+
(action) => {
|
|
325
|
+
const { collector, batches } = setup();
|
|
326
|
+
document.body.innerHTML = "<input>";
|
|
327
|
+
collector.start({ trajectoryId: "input-session" });
|
|
328
|
+
const input = document.querySelector("input")!;
|
|
329
|
+
input.value = "final text";
|
|
330
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
331
|
+
if (action === "emit") collector.emit("app.approved", {});
|
|
332
|
+
else if (action === "pagehide") {
|
|
333
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
334
|
+
expect(names(batches)).toEqual(["page", "input"]);
|
|
335
|
+
expect(batches[0]?.events[1]?.value).toMatchObject({
|
|
336
|
+
target: { value: "final text" },
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
collector.stop();
|
|
340
|
+
const events = batches.flatMap((batch) => batch.events);
|
|
341
|
+
expect(events.map((event) => event.name)).toEqual([
|
|
342
|
+
"page",
|
|
343
|
+
"input",
|
|
344
|
+
...(action === "emit" ? ["app.approved"] : []),
|
|
345
|
+
]);
|
|
346
|
+
expect(events.map((event) => event.value.seq)).toEqual(
|
|
347
|
+
action === "emit" ? [1, 2, 3] : [1, 2],
|
|
348
|
+
);
|
|
349
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
350
|
+
},
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
it("records input before a fast Enter-to-send network response outside a form", async () => {
|
|
354
|
+
const beforeSend = vi.fn<NonNullable<CollectorOptions["beforeSend"]>>(
|
|
355
|
+
(event) => event,
|
|
356
|
+
);
|
|
357
|
+
const { collector } = setup({ beforeSend });
|
|
358
|
+
document.body.innerHTML = "<input>";
|
|
359
|
+
collector.start({ trajectoryId: "enter-session" });
|
|
360
|
+
const input = document.querySelector("input")!;
|
|
361
|
+
let response: Promise<Response> | undefined;
|
|
362
|
+
input.addEventListener("keydown", (event) => {
|
|
363
|
+
if (event.key !== "Enter") return;
|
|
364
|
+
response = fetch("/todos", { method: "POST" });
|
|
365
|
+
input.value = "";
|
|
366
|
+
});
|
|
367
|
+
input.value = "Buy milk";
|
|
368
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
369
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
370
|
+
input.dispatchEvent(
|
|
371
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
|
372
|
+
);
|
|
373
|
+
await response;
|
|
374
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
375
|
+
|
|
376
|
+
const events = beforeSend.mock.calls.map(([event]) => event);
|
|
377
|
+
expect(events.map((event) => event.name)).toEqual([
|
|
378
|
+
"page",
|
|
379
|
+
"input",
|
|
380
|
+
"network",
|
|
381
|
+
]);
|
|
382
|
+
expect(events.map((event) => event.value.seq)).toEqual([1, 2, 3]);
|
|
383
|
+
expect(events[1]).toMatchObject({
|
|
384
|
+
timestamp: NOW + 100,
|
|
385
|
+
value: { target: { value: "Buy milk" } },
|
|
386
|
+
});
|
|
387
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
388
|
+
expect(beforeSend).toHaveBeenCalledTimes(3);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("network capture does not flush or delay pending text in the batched collector", async () => {
|
|
392
|
+
const beforeSend = vi.fn<NonNullable<CollectorOptions["beforeSend"]>>(
|
|
393
|
+
(event) => event,
|
|
394
|
+
);
|
|
395
|
+
const { collector, batches } = setup({ beforeSend });
|
|
396
|
+
document.body.innerHTML = "<input>";
|
|
397
|
+
collector.start({ trajectoryId: "typeahead-session" });
|
|
398
|
+
const input = document.querySelector("input")!;
|
|
399
|
+
input.value = "pending search";
|
|
400
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
401
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
402
|
+
|
|
403
|
+
await fetch("/typeahead?q=pending");
|
|
404
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
405
|
+
|
|
406
|
+
expect(beforeSend.mock.calls.map(([event]) => event.name)).toEqual([
|
|
407
|
+
"page",
|
|
408
|
+
"network",
|
|
409
|
+
]);
|
|
410
|
+
await vi.advanceTimersByTimeAsync(199);
|
|
411
|
+
expect(beforeSend.mock.calls.map(([event]) => event.name)).toEqual([
|
|
412
|
+
"page",
|
|
413
|
+
"network",
|
|
414
|
+
]);
|
|
415
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
416
|
+
expect(beforeSend.mock.calls.map(([event]) => event.name)).toEqual([
|
|
417
|
+
"page",
|
|
418
|
+
"network",
|
|
419
|
+
"input",
|
|
420
|
+
]);
|
|
421
|
+
expect(beforeSend.mock.calls[2]?.[0]).toMatchObject({
|
|
422
|
+
timestamp: NOW + 300,
|
|
423
|
+
value: { seq: 3, target: { value: "pending search" } },
|
|
424
|
+
});
|
|
425
|
+
collector.stop();
|
|
426
|
+
expect(names(batches)).toEqual(["page", "network", "input"]);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("a reentrant stop/restart during input flush preserves the new batched session", () => {
|
|
430
|
+
const { collector, batches } = setup({
|
|
431
|
+
beforeSend(event) {
|
|
432
|
+
if (event.name === "input") {
|
|
433
|
+
collector.stop();
|
|
434
|
+
collector.start({ trajectoryId: "new-session" });
|
|
435
|
+
}
|
|
436
|
+
return event;
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
document.body.innerHTML = "<input>";
|
|
440
|
+
collector.start({ trajectoryId: "old-session" });
|
|
441
|
+
document.querySelector("input")!.value = "pending";
|
|
442
|
+
document
|
|
443
|
+
.querySelector("input")!
|
|
444
|
+
.dispatchEvent(new Event("input", { bubbles: true }));
|
|
445
|
+
collector.stop();
|
|
446
|
+
expect(collector.trajectoryId).toBe("new-session");
|
|
447
|
+
collector.emit("new.action", {});
|
|
448
|
+
collector.stop();
|
|
449
|
+
expect(
|
|
450
|
+
batches.map((batch) => [
|
|
451
|
+
batch.trajectoryId,
|
|
452
|
+
batch.events.map((event) => event.name),
|
|
453
|
+
]),
|
|
454
|
+
).toEqual([
|
|
455
|
+
["old-session", ["page"]],
|
|
456
|
+
["new-session", ["page", "new.action"]],
|
|
457
|
+
]);
|
|
458
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
459
|
+
});
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { installInputCapture } from "../inputs";
|
|
3
|
+
import { createRedactor } from "../redact";
|
|
3
4
|
|
|
4
5
|
let uninstall: (() => void) | undefined;
|
|
5
6
|
afterEach(() => {
|
|
6
7
|
uninstall?.();
|
|
7
8
|
document.body.innerHTML = "";
|
|
9
|
+
vi.useRealTimers();
|
|
8
10
|
});
|
|
9
11
|
|
|
10
12
|
describe("input capture", () => {
|
|
11
13
|
it("captures edited text, checkbox state, and selected values, and redacts passwords", () => {
|
|
14
|
+
vi.useFakeTimers();
|
|
12
15
|
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
16
|
const events: Record<string, unknown>[] = [];
|
|
14
17
|
uninstall = installInputCapture({
|
|
@@ -33,6 +36,7 @@ describe("input capture", () => {
|
|
|
33
36
|
element.checked = true;
|
|
34
37
|
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
35
38
|
}
|
|
39
|
+
vi.advanceTimersByTime(300);
|
|
36
40
|
expect(events).toHaveLength(6);
|
|
37
41
|
expect(events[0]).toMatchObject({
|
|
38
42
|
name: "input",
|
|
@@ -75,3 +79,398 @@ describe("input capture", () => {
|
|
|
75
79
|
expect(events).toEqual([]);
|
|
76
80
|
});
|
|
77
81
|
});
|
|
82
|
+
|
|
83
|
+
function inputFixture(
|
|
84
|
+
markup = '<input id="first"><textarea id="second"></textarea>',
|
|
85
|
+
) {
|
|
86
|
+
vi.useFakeTimers();
|
|
87
|
+
document.body.innerHTML = markup;
|
|
88
|
+
const emit = vi.fn();
|
|
89
|
+
const capture = installInputCapture({ emit, isTrusted: () => true });
|
|
90
|
+
uninstall = capture;
|
|
91
|
+
const edit = (id: string, value: string, type = "input") => {
|
|
92
|
+
const field = document.getElementById(id) as HTMLInputElement;
|
|
93
|
+
field.value = value;
|
|
94
|
+
field.dispatchEvent(new Event(type, { bubbles: true }));
|
|
95
|
+
return field;
|
|
96
|
+
};
|
|
97
|
+
return { emit, edit, capture };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it("debounces each text field for 300 ms and collapses input/change duplicates", () => {
|
|
101
|
+
const { emit, edit } = inputFixture();
|
|
102
|
+
edit("first", "a");
|
|
103
|
+
vi.advanceTimersByTime(200);
|
|
104
|
+
edit("first", "approved");
|
|
105
|
+
edit("first", "approved");
|
|
106
|
+
expect(emit).not.toHaveBeenCalled();
|
|
107
|
+
vi.advanceTimersByTime(299);
|
|
108
|
+
expect(emit).not.toHaveBeenCalled();
|
|
109
|
+
vi.advanceTimersByTime(1);
|
|
110
|
+
expect(emit.mock.calls.map(([, value]) => value.target.value)).toEqual([
|
|
111
|
+
"approved",
|
|
112
|
+
]);
|
|
113
|
+
edit("first", "approved", "change");
|
|
114
|
+
vi.advanceTimersByTime(300);
|
|
115
|
+
expect(emit).toHaveBeenCalledOnce();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("keeps discrete controls immediate and flushes prior text before them", () => {
|
|
119
|
+
const { emit, edit } = inputFixture(
|
|
120
|
+
'<input id="text"><input id="check" type="checkbox"><input id="range" type="range"><select id="select"><option value="a">A</option></select>',
|
|
121
|
+
);
|
|
122
|
+
edit("text", "final");
|
|
123
|
+
expect(emit).not.toHaveBeenCalled();
|
|
124
|
+
edit("check", "yes");
|
|
125
|
+
edit("check", "yes", "change");
|
|
126
|
+
edit("range", "60");
|
|
127
|
+
edit("select", "a", "change");
|
|
128
|
+
expect(
|
|
129
|
+
emit.mock.calls.map(([, value]) => value.target.attributes.id),
|
|
130
|
+
).toEqual(["text", "check", "range", "select"]);
|
|
131
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("waits for composition completion and emits the final value once", () => {
|
|
135
|
+
const { emit, edit } = inputFixture();
|
|
136
|
+
const field = document.getElementById("first")!;
|
|
137
|
+
edit("first", "prefix");
|
|
138
|
+
field.dispatchEvent(
|
|
139
|
+
new CompositionEvent("compositionstart", { bubbles: true }),
|
|
140
|
+
);
|
|
141
|
+
edit("first", "partial");
|
|
142
|
+
vi.advanceTimersByTime(1000);
|
|
143
|
+
expect(emit).not.toHaveBeenCalled();
|
|
144
|
+
(field as HTMLInputElement).value = "finished";
|
|
145
|
+
field.dispatchEvent(
|
|
146
|
+
new CompositionEvent("compositionend", { bubbles: true }),
|
|
147
|
+
);
|
|
148
|
+
edit("first", "finished");
|
|
149
|
+
vi.advanceTimersByTime(300);
|
|
150
|
+
expect(emit).toHaveBeenCalledOnce();
|
|
151
|
+
expect(emit).toHaveBeenCalledWith(
|
|
152
|
+
"input",
|
|
153
|
+
expect.objectContaining({
|
|
154
|
+
target: expect.objectContaining({ value: "finished" }),
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it.each(["reset", "ignore", "password"])(
|
|
160
|
+
"flushes original snapshots before submit and rechecks %s",
|
|
161
|
+
(change) => {
|
|
162
|
+
const { emit, edit } = inputFixture(
|
|
163
|
+
'<form><input id="first" data-copy="original"></form>',
|
|
164
|
+
);
|
|
165
|
+
const input = edit("first", "original");
|
|
166
|
+
expect(emit).not.toHaveBeenCalled();
|
|
167
|
+
if (change === "reset") input.value = "programmatically reset";
|
|
168
|
+
else if (change === "ignore")
|
|
169
|
+
input.setAttribute("data-copilotkit-ignore", "");
|
|
170
|
+
else input.type = "password";
|
|
171
|
+
document
|
|
172
|
+
.querySelector("form")!
|
|
173
|
+
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
174
|
+
expect(emit.mock.calls.map(([, value]) => value.target.value)).toEqual(
|
|
175
|
+
change === "reset" ? ["original"] : [],
|
|
176
|
+
);
|
|
177
|
+
if (change !== "reset")
|
|
178
|
+
expect(JSON.stringify(emit.mock.calls)).not.toContain("original");
|
|
179
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
it("uninstall cancels pending edits and removes flush/composition listeners", () => {
|
|
184
|
+
const { emit, edit } = inputFixture();
|
|
185
|
+
edit("first", "pending");
|
|
186
|
+
expect(emit).not.toHaveBeenCalled();
|
|
187
|
+
uninstall?.();
|
|
188
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
189
|
+
window.dispatchEvent(new Event("click"));
|
|
190
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
191
|
+
document
|
|
192
|
+
.getElementById("first")!
|
|
193
|
+
.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
|
|
194
|
+
vi.advanceTimersByTime(1000);
|
|
195
|
+
expect(emit).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it.each(["change", "focusout", "other-field"])(
|
|
199
|
+
"commits text immediately on %s without a duplicate record",
|
|
200
|
+
(action) => {
|
|
201
|
+
const { emit, edit } = inputFixture();
|
|
202
|
+
const input = edit("first", "committed");
|
|
203
|
+
expect(emit).not.toHaveBeenCalled();
|
|
204
|
+
if (action === "other-field") edit("second", "next");
|
|
205
|
+
else input.dispatchEvent(new Event(action, { bubbles: true }));
|
|
206
|
+
expect(emit.mock.calls.map(([, value]) => value.target.value)).toEqual([
|
|
207
|
+
"committed",
|
|
208
|
+
]);
|
|
209
|
+
vi.advanceTimersByTime(300);
|
|
210
|
+
expect(emit.mock.calls.map(([, value]) => value.target.value)).toEqual(
|
|
211
|
+
action === "other-field" ? ["committed", "next"] : ["committed"],
|
|
212
|
+
);
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
it("does not leak a pending snapshot when a host resets and then marks the field as password", () => {
|
|
217
|
+
const { emit, edit } = inputFixture(
|
|
218
|
+
'<input id="first" data-copy="original">',
|
|
219
|
+
);
|
|
220
|
+
const input = edit("first", "original");
|
|
221
|
+
input.value = "reset-value";
|
|
222
|
+
input.type = "password";
|
|
223
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
224
|
+
expect(JSON.stringify(emit.mock.calls)).not.toContain("original");
|
|
225
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it.each(["text", "password", "checkbox", "select"])(
|
|
229
|
+
"records a later trusted %s input after the host resets the same value",
|
|
230
|
+
(type) => {
|
|
231
|
+
const markup =
|
|
232
|
+
type === "select"
|
|
233
|
+
? '<select id="first"><option value="">Empty</option><option value="same">Same</option></select>'
|
|
234
|
+
: `<input id="first" type="${type}">`;
|
|
235
|
+
const { emit, edit } = inputFixture(markup);
|
|
236
|
+
const field = document.querySelector<HTMLInputElement | HTMLSelectElement>(
|
|
237
|
+
"#first",
|
|
238
|
+
)!;
|
|
239
|
+
for (let occurrence = 0; occurrence < 2; occurrence++) {
|
|
240
|
+
field.value = "";
|
|
241
|
+
if (field instanceof HTMLInputElement && type === "checkbox")
|
|
242
|
+
field.checked = false;
|
|
243
|
+
if (field instanceof HTMLInputElement && type === "checkbox")
|
|
244
|
+
field.checked = true;
|
|
245
|
+
edit("first", "same");
|
|
246
|
+
vi.advanceTimersByTime(300);
|
|
247
|
+
edit("first", "same", "change");
|
|
248
|
+
}
|
|
249
|
+
expect(emit).toHaveBeenCalledTimes(2);
|
|
250
|
+
expect(emit.mock.calls.map(([, value]) => value.target.value)).toEqual(
|
|
251
|
+
type === "password" ? ["[redacted]", "[redacted]"] : ["same", "same"],
|
|
252
|
+
);
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
it.each(["checkbox", "select"])(
|
|
257
|
+
"does not deduplicate independent %s change events",
|
|
258
|
+
(type) => {
|
|
259
|
+
const { emit, edit } = inputFixture(
|
|
260
|
+
type === "select"
|
|
261
|
+
? '<select id="first"><option value="">Empty</option><option value="same">Same</option></select>'
|
|
262
|
+
: '<input id="first" type="checkbox">',
|
|
263
|
+
);
|
|
264
|
+
const field = document.querySelector<HTMLInputElement | HTMLSelectElement>(
|
|
265
|
+
"#first",
|
|
266
|
+
)!;
|
|
267
|
+
for (let occurrence = 0; occurrence < 2; occurrence++) {
|
|
268
|
+
field.value = "";
|
|
269
|
+
if (field instanceof HTMLInputElement) field.checked = false;
|
|
270
|
+
if (field instanceof HTMLInputElement) field.checked = true;
|
|
271
|
+
edit("first", "same", "change");
|
|
272
|
+
}
|
|
273
|
+
expect(emit).toHaveBeenCalledTimes(2);
|
|
274
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
it("settles redacted input 300 ms after the latest trusted edit", () => {
|
|
279
|
+
const { emit, edit } = inputFixture('<input id="first" type="password">');
|
|
280
|
+
edit("first", "first-secret");
|
|
281
|
+
vi.advanceTimersByTime(200);
|
|
282
|
+
edit("first", "second-secret");
|
|
283
|
+
vi.advanceTimersByTime(299);
|
|
284
|
+
expect(emit).not.toHaveBeenCalled();
|
|
285
|
+
vi.advanceTimersByTime(1);
|
|
286
|
+
expect(emit).toHaveBeenCalledOnce();
|
|
287
|
+
expect(emit.mock.calls[0]?.[1].target.value).toBe("[redacted]");
|
|
288
|
+
expect(JSON.stringify(emit.mock.calls)).not.toContain("secret");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it.each(["flush", "click", "submit", "focusout", "pagehide"])(
|
|
292
|
+
"preserves the latest composing input at the %s boundary",
|
|
293
|
+
(boundary) => {
|
|
294
|
+
const { emit, capture } = inputFixture();
|
|
295
|
+
const field = document.querySelector<HTMLInputElement>("#first")!;
|
|
296
|
+
field.dispatchEvent(
|
|
297
|
+
new CompositionEvent("compositionstart", { bubbles: true }),
|
|
298
|
+
);
|
|
299
|
+
for (const value of ["partial", "latest user text"]) {
|
|
300
|
+
field.value = value;
|
|
301
|
+
field.dispatchEvent(
|
|
302
|
+
new InputEvent("input", { bubbles: true, isComposing: true }),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
vi.advanceTimersByTime(1000);
|
|
306
|
+
expect(emit).not.toHaveBeenCalled();
|
|
307
|
+
field.value = "host reset";
|
|
308
|
+
if (boundary === "flush") capture.flush();
|
|
309
|
+
else field.dispatchEvent(new Event(boundary, { bubbles: true }));
|
|
310
|
+
expect(emit.mock.calls.map(([, value]) => value.target.value)).toEqual([
|
|
311
|
+
"latest user text",
|
|
312
|
+
]);
|
|
313
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
it("does not let a late composing input after compositionend poison the field", () => {
|
|
318
|
+
const { emit, edit } = inputFixture();
|
|
319
|
+
const field = document.querySelector<HTMLInputElement>("#first")!;
|
|
320
|
+
field.dispatchEvent(
|
|
321
|
+
new CompositionEvent("compositionstart", { bubbles: true }),
|
|
322
|
+
);
|
|
323
|
+
field.value = "finished";
|
|
324
|
+
field.dispatchEvent(
|
|
325
|
+
new CompositionEvent("compositionend", { bubbles: true }),
|
|
326
|
+
);
|
|
327
|
+
field.dispatchEvent(
|
|
328
|
+
new InputEvent("input", { bubbles: true, isComposing: true }),
|
|
329
|
+
);
|
|
330
|
+
vi.advanceTimersByTime(300);
|
|
331
|
+
expect(emit.mock.calls.map(([, value]) => value.target.value)).toEqual([
|
|
332
|
+
"finished",
|
|
333
|
+
]);
|
|
334
|
+
edit("first", "next edit");
|
|
335
|
+
vi.advanceTimersByTime(300);
|
|
336
|
+
expect(emit.mock.calls.map(([, value]) => value.target.value)).toEqual([
|
|
337
|
+
"finished",
|
|
338
|
+
"next edit",
|
|
339
|
+
]);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("flushes trusted Enter before a no-form send handler resets the input", () => {
|
|
343
|
+
const { emit, edit } = inputFixture();
|
|
344
|
+
const order: string[] = [];
|
|
345
|
+
emit.mockImplementation(() => order.push("input"));
|
|
346
|
+
const field = edit("first", "send this");
|
|
347
|
+
field.addEventListener("keydown", () => {
|
|
348
|
+
order.push("send");
|
|
349
|
+
field.value = "";
|
|
350
|
+
});
|
|
351
|
+
field.dispatchEvent(
|
|
352
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
|
353
|
+
);
|
|
354
|
+
expect(order).toEqual(["input", "send"]);
|
|
355
|
+
expect(emit.mock.calls[0]?.[1].target.value).toBe("send this");
|
|
356
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it.each(["isComposing", "keyCode", "compositionstart"])(
|
|
360
|
+
"keeps IME Enter from flushing partial text identified by %s",
|
|
361
|
+
(signal) => {
|
|
362
|
+
const { emit } = inputFixture();
|
|
363
|
+
const field = document.querySelector<HTMLInputElement>("#first")!;
|
|
364
|
+
if (signal === "compositionstart")
|
|
365
|
+
field.dispatchEvent(
|
|
366
|
+
new CompositionEvent("compositionstart", { bubbles: true }),
|
|
367
|
+
);
|
|
368
|
+
field.value = "partial";
|
|
369
|
+
field.dispatchEvent(
|
|
370
|
+
new InputEvent("input", { bubbles: true, isComposing: true }),
|
|
371
|
+
);
|
|
372
|
+
field.dispatchEvent(
|
|
373
|
+
new KeyboardEvent("keydown", {
|
|
374
|
+
key: "Enter",
|
|
375
|
+
bubbles: true,
|
|
376
|
+
isComposing: signal === "isComposing",
|
|
377
|
+
keyCode: signal === "keyCode" ? 229 : 13,
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
expect(emit).not.toHaveBeenCalled();
|
|
381
|
+
field.value = "finished";
|
|
382
|
+
field.dispatchEvent(
|
|
383
|
+
new CompositionEvent("compositionend", { bubbles: true }),
|
|
384
|
+
);
|
|
385
|
+
vi.advanceTimersByTime(300);
|
|
386
|
+
expect(emit.mock.calls.map(([, value]) => value.target.value)).toEqual([
|
|
387
|
+
"finished",
|
|
388
|
+
]);
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
it("ignores IME keydown 229 even after compositionend", () => {
|
|
393
|
+
const { emit } = inputFixture();
|
|
394
|
+
const field = document.querySelector<HTMLInputElement>("#first")!;
|
|
395
|
+
field.dispatchEvent(
|
|
396
|
+
new CompositionEvent("compositionstart", { bubbles: true }),
|
|
397
|
+
);
|
|
398
|
+
field.value = "finished";
|
|
399
|
+
field.dispatchEvent(
|
|
400
|
+
new CompositionEvent("compositionend", { bubbles: true }),
|
|
401
|
+
);
|
|
402
|
+
field.dispatchEvent(
|
|
403
|
+
new KeyboardEvent("keydown", { key: "Enter", keyCode: 229, bubbles: true }),
|
|
404
|
+
);
|
|
405
|
+
expect(emit).not.toHaveBeenCalled();
|
|
406
|
+
vi.advanceTimersByTime(299);
|
|
407
|
+
expect(emit).not.toHaveBeenCalled();
|
|
408
|
+
vi.advanceTimersByTime(1);
|
|
409
|
+
expect(emit.mock.calls.map(([, value]) => value.target.value)).toEqual([
|
|
410
|
+
"finished",
|
|
411
|
+
]);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it.each(["untrusted Enter", "ordinary key"])(
|
|
415
|
+
"does not flush on %s",
|
|
416
|
+
(action) => {
|
|
417
|
+
vi.useFakeTimers();
|
|
418
|
+
document.body.innerHTML = '<input id="first">';
|
|
419
|
+
const emit = vi.fn();
|
|
420
|
+
uninstall = installInputCapture({
|
|
421
|
+
emit,
|
|
422
|
+
isTrusted: (event) =>
|
|
423
|
+
event.type !== "keydown" || action === "ordinary key",
|
|
424
|
+
});
|
|
425
|
+
const field = document.querySelector<HTMLInputElement>("#first")!;
|
|
426
|
+
field.value = "pending";
|
|
427
|
+
field.dispatchEvent(new InputEvent("input", { bubbles: true }));
|
|
428
|
+
field.dispatchEvent(
|
|
429
|
+
new KeyboardEvent("keydown", {
|
|
430
|
+
key: action === "ordinary key" ? "a" : "Enter",
|
|
431
|
+
bubbles: true,
|
|
432
|
+
}),
|
|
433
|
+
);
|
|
434
|
+
expect(emit).not.toHaveBeenCalled();
|
|
435
|
+
vi.advanceTimersByTime(300);
|
|
436
|
+
expect(emit.mock.calls.map(([, value]) => value.target.value)).toEqual([
|
|
437
|
+
"pending",
|
|
438
|
+
]);
|
|
439
|
+
},
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
it("rechecks redaction when explicitly flushing a composing snapshot", () => {
|
|
443
|
+
vi.useFakeTimers();
|
|
444
|
+
document.body.innerHTML =
|
|
445
|
+
'<input id="first" data-copy="new-secret"><input id="password" type="password">';
|
|
446
|
+
const redact = createRedactor();
|
|
447
|
+
const emit = vi.fn();
|
|
448
|
+
const capture = installInputCapture({ emit, redact, isTrusted: () => true });
|
|
449
|
+
uninstall = capture;
|
|
450
|
+
const field = document.querySelector<HTMLInputElement>("#first")!;
|
|
451
|
+
field.dispatchEvent(
|
|
452
|
+
new CompositionEvent("compositionstart", { bubbles: true }),
|
|
453
|
+
);
|
|
454
|
+
field.value = "safe text";
|
|
455
|
+
field.dispatchEvent(
|
|
456
|
+
new InputEvent("input", { bubbles: true, isComposing: true }),
|
|
457
|
+
);
|
|
458
|
+
const password = document.querySelector<HTMLInputElement>("#password")!;
|
|
459
|
+
password.value = "new-secret";
|
|
460
|
+
redact.isPassword(password);
|
|
461
|
+
capture.flush();
|
|
462
|
+
expect(emit).toHaveBeenCalledOnce();
|
|
463
|
+
expect(emit.mock.calls[0]?.[1].target.attributes["data-copy"]).toBe(
|
|
464
|
+
"[redacted]",
|
|
465
|
+
);
|
|
466
|
+
expect(JSON.stringify(emit.mock.calls)).not.toContain("new-secret");
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("does not add the click label to input events", () => {
|
|
470
|
+
const { emit, edit } = inputFixture(
|
|
471
|
+
'<input id="first" aria-label="Customer name">',
|
|
472
|
+
);
|
|
473
|
+
edit("first", "Jane", "change");
|
|
474
|
+
expect(emit).toHaveBeenCalledOnce();
|
|
475
|
+
expect(emit.mock.calls[0]?.[1].target).not.toHaveProperty("label");
|
|
476
|
+
});
|
|
@@ -360,10 +360,10 @@ describe("password-like fields", () => {
|
|
|
360
360
|
input.setAttribute("value", "toggle-secret-2");
|
|
361
361
|
type(input, "toggle-secret-2");
|
|
362
362
|
input.click();
|
|
363
|
-
expect(other.map(({ name }) => name)).toEqual(["input", "
|
|
363
|
+
expect(other.map(({ name }) => name)).toEqual(["input", "click"]);
|
|
364
364
|
for (const { value } of other)
|
|
365
365
|
expect(value.target).toMatchObject({ value: "[redacted]" });
|
|
366
|
-
expect(other[
|
|
366
|
+
expect(other[1]!.value.target).toMatchObject({
|
|
367
367
|
attributes: { value: "[redacted]" },
|
|
368
368
|
});
|
|
369
369
|
expect(JSON.stringify(other)).not.toContain("toggle-secret");
|
|
@@ -375,6 +375,7 @@ describe("password-like fields", () => {
|
|
|
375
375
|
document.body.innerHTML = `<input type="text" autocomplete="${autocomplete}">`;
|
|
376
376
|
const { other } = setup();
|
|
377
377
|
type(document.querySelector("input")!, "autocomplete-secret");
|
|
378
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
378
379
|
expect(other[0]!.value.target).toMatchObject({ value: "[redacted]" });
|
|
379
380
|
},
|
|
380
381
|
);
|
|
@@ -383,6 +384,7 @@ describe("password-like fields", () => {
|
|
|
383
384
|
document.body.innerHTML = '<input name="email" type="email">';
|
|
384
385
|
const { other } = setup();
|
|
385
386
|
type(document.querySelector("input")!, "alice@example.com");
|
|
387
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
386
388
|
expect(other[0]!.value.target).toMatchObject({
|
|
387
389
|
value: "alice@example.com",
|
|
388
390
|
});
|