fix(input): emit native InputEvent metadata for text editing

This commit is contained in:
ldm0
2026-09-28 11:25:39 +08:00
parent dfc670a9ae
commit 253be36293
4 changed files with 368 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
# 原生文本编辑 InputEvent 补齐
## 改动
本次针对已有的非 IME 文本编辑链路,不改变输入节奏或网站统计。
- 共用原生 `InputEvent` 构造器。文本输入携带 `data` 和 `insertText`;
textarea Enter 使用 `insertLineBreak`;Backspace/Delete 分别使用
`deleteContentBackward` / `deleteContentForward`。换行和删除的 data 为 null。
- 非合成的 beforeinput/input 为可信 InputEvent,继承 UIEvent;两者
bubbles/composed=true,只有 beforeinput 可取消,isComposing=false。
- 使用保存的 intrinsic constructor,不调用页面替换的 `window.InputEvent`。
- 删除操作在 beforeinput 之后才扩展删除范围。取消事件不会先修改光标/选区。
- 文本控件拖入文本的已有替换链路携带 insertFromDrop;execCommand 的
既有文本插入/删除事件也使用对应类型。
- checkbox、select 等非文本控件事件和 blur 后的 change 仍为普通 Event。
边界:这不是完整编辑器实现。没有增加 IME、富文本拖放的 DataTransfer、
getTargetRanges、contenteditable 的完整删除/段落算法。富文本 drop 的
既有独立链路尚未迁入本 helper,不声称所有 beforeinput 入口已完全一致。
没有改变同步布局冻结策略、Windows 默认身份或 WebGL。
## 可执行验证
`moli-protocol/src/domains/input/tests/keyboard_events.rs` 的新测试覆盖:
1. input、textarea、contenteditable 的 keyDown/char/Input.insertText 元数据。
2. input、textarea 双向删除的值、方向和 null data。
3. 取消 beforeinput 后值和光标保持不变。
4. textarea Enter 的换行类型与 null data。
5. 页面覆盖 InputEvent 构造器不影响原生事件;change 不变成 InputEvent。
扩充的 `dom-input` CDP smoke 已在真实 Chromium 145.0.7632.116(xvfb)
和本次 Moli debug、release 二进制上通过。测试使用真实 Input.* 命令和
Playwright keyboard,未仅验证 JS 自行构造的 InputEvent。
本地证据目录:
- `docs/inner/input-event-chromium-extended-20260912/`
- `docs/inner/input-event-moli-debug-20260912/`
- `docs/inner/input-event-moli-release-20260912/`
## 门禁完整记录
`cargo fmt --all`、全 workspace/all-targets/all-features Clippy
(`-D warnings`)通过;额外 `cargo fmt --all --check` 通过。
不隐去提交前的失败运行:
| 全量运行 | 结果 | 失败情况 |
| --- | --- | --- |
| 首次 | 17537/17541 通过 | 磁盘耗尽使三个 WPT 写报告失败;另有 realtime audio 赋值 880 后读回 440 |
| 恢复空间后 | 17540/17541 通过 | 既有 follows_post_parse_timeout_location_assign_during_page_load 的 2 秒等待未到目标页 |
| 首次限制并发 | 17540/17541 通过 | 编译期间提交取证文档,使 version 测试的编译期 HEAD 与运行期 HEAD 不同;属于操作问题,不是产品修复 |
| 固定 HEAD,最终验证 | **17541/17541 通过** | 147.880 秒,13 项为仓库原有 skip |
没有修改这两个无关测试/生产实现。音频单测有界 stress 10/10 通过;
所在 eval 模块 5/5 轮、每轮 12 项通过;导航单测 stress 10/10 通过。
这些不证明间歇问题已修复。并发构建/测试负载下的导航 deadline 和实时
音频共享值仍是已记录风险,不用窄测试通过掩盖全量失败。
最终保持 HEAD `5c53546e1` 不动,重新按顺序完成以下验证(从仓库根目录,
Cargo 使用独立的可重建 target cache):
```sh
cargo fmt --all
cargo clippy --workspace --all-targets --all-features -- -D warnings
NEXTEST_TEST_THREADS=16 cargo nextest run --no-fail-fast --status-level fail --final-status-level fail
```
使用同一 workspace 和断言,只限制并发;没有跳过用例、加重试、增加超时
或放宽断言。最终 nextest run ID 为 `27d134e7-b626-4160-89d6-0ada2fc06273`。
日志为 `docs/inner/input-event-clippy-final-20260912.log` 和
`docs/inner/input-event-nextest-final-stable-head-20260912.log`。
Python smoke 项目全部 46 项单元测试也通过。
## 网站收益边界
DeviceAndBrowser 的完整对照见 `device-browser-behavior-2026-09-12.md`。
两端均正常提交且收到最终 verdict;邮箱/密码 numKeys 均为 25/20,
仍共同命中 suspiciousClientSideBehavior。本改动修复 API 兼容性,
不能算作新增一个通过的检测站点。
+9
View File
@@ -6,6 +6,15 @@ The suite covers real CDP-client workflows: `connect_over_cdp`, concurrent brows
## Chromium Behavior Evidence
The `dom-input` text-editing checks were calibrated on 2026-09-12 against
Debian `/usr/bin/chromium` 145.0.7632.116 under xvfb and run unchanged against
Moli. `Input.insertText` on input, textarea and contenteditable elements emits
trusted `InputEvent` instances with `data`, `inputType` and `isComposing`;
textarea Enter and text-control Backspace/Delete have null data and distinct
operation types. `beforeinput` is cancelable; `input` is not. Both bubble and
are composed. This does not claim coverage of IME composition or rich-text
transfer/target-range semantics.
Any smoke assertion described as Chromium-compatible must be verified against a
real Chromium binary. Do not infer observable behavior solely from the
specification, Chromium source code, an existing Moli test, or intuition.
@@ -1012,10 +1012,17 @@ async def run_keypress_dispatch_workflow(state: SmokeState) -> None:
<input id="field">
<script>
window.__keyEvents = [];
window.__editEvents = [];
window.__cancelAt = false;
for (const type of ['keydown', 'keypress', 'beforeinput', 'input', 'keyup']) {
document.getElementById('field').addEventListener(type, event => {
__keyEvents.push({type, trusted: event.isTrusted});
if (type === 'beforeinput' || type === 'input') {
__editEvents.push({type, data: event.data, inputType: event.inputType,
isComposing: event.isComposing, native: event instanceof InputEvent,
trusted: event.isTrusted, bubbles: event.bubbles,
cancelable: event.cancelable, composed: event.composed});
}
if (__cancelAt && type === 'keypress' && event.key === '@')
event.preventDefault();
});
@@ -1033,6 +1040,7 @@ async def run_keypress_dispatch_workflow(state: SmokeState) -> None:
field.value = '';
field.focus();
window.__keyEvents = [];
window.__editEvents = [];
window.__cancelAt = cancelAt;
}""", cancel_at)
@@ -1073,7 +1081,49 @@ async def run_keypress_dispatch_workflow(state: SmokeState) -> None:
await reset()
await session.send("Input.insertText", {"text": "a"})
await check("a", ["beforeinput", "input"], "insertText does not synthesize keyboard events")
async def check_edit(input_type: str, data: str | None) -> None:
assert_equal(
await page.evaluate("__editEvents"),
[{"type": kind, "data": data, "inputType": input_type,
"isComposing": False, "native": True, "trusted": True,
"bubbles": True, "cancelable": kind == "beforeinput", "composed": True}
for kind in ["beforeinput", "input"]],
f"native InputEvent payload for {input_type}",
)
await check_edit("insertText", "a")
for key, input_type, expected in [
("Backspace", "deleteContentBackward", "ac"),
("Delete", "deleteContentForward", "ab"),
]:
await reset()
await page.evaluate("field.value = 'abc'; field.setSelectionRange(2, 2)")
await page.keyboard.press(key)
await check(expected, ["keydown", "beforeinput", "input", "keyup"], f"{key} editing")
await check_edit(input_type, None)
for control in ['<textarea id="field"></textarea>', '<div id="field" contenteditable="true"></div>']:
await page.set_content(control)
await page.evaluate("""() => {
window.__editEvents = [];
for (const type of ['beforeinput', 'input']) field.addEventListener(type, event => {
__editEvents.push({type, data: event.data, inputType: event.inputType,
isComposing: event.isComposing, native: event instanceof InputEvent,
trusted: event.isTrusted, bubbles: event.bubbles,
cancelable: event.cancelable, composed: event.composed});
});
field.focus();
}""")
await session.send("Input.insertText", {"text": "hello"})
await check_edit("insertText", "hello")
if control.startswith('<textarea'):
await page.evaluate("__editEvents = []")
await page.keyboard.press("Enter")
await check_edit("insertLineBreak", None)
state.record("playwright_and_cdp_keypress_dispatch")
state.record("native_text_input_event_payloads")
finally:
if session is not None:
await await_with_progress("keypress/detach", session.detach(), timeout_seconds=5)
@@ -17,6 +17,12 @@ async fn keyboard_fixture(control: &str) -> TestContext {
for (const type of ['keydown', 'keypress', 'beforeinput', 'input', 'keyup']) {
document.addEventListener(type, event => {
__events.push(event.type + ':' + event.target.id);
if (type === 'beforeinput' || type === 'input') {
__inputEvents.push({type, data: event.data, inputType: event.inputType,
isComposing: event.isComposing, native: event instanceof InputEvent,
ui: event instanceof UIEvent, trusted: event.isTrusted,
bubbles: event.bubbles, cancelable: event.cancelable, composed: event.composed});
}
if (type === 'keypress') {
__keypresses.push({key: event.key, code: event.code,
keyCode: event.keyCode, charCode: event.charCode,
@@ -510,3 +516,224 @@ async fn cdp_deletion_at_text_boundary_still_dispatches_beforeinput_without_edit
}
}
}
fn expected_input_event(
event_type: &str,
input_type: &str,
data: serde_json::Value,
) -> serde_json::Value {
json!({"type": event_type, "data": data, "inputType": input_type,
"isComposing": false, "native": true, "ui": true, "trusted": true,
"bubbles": true, "cancelable": event_type == "beforeinput", "composed": true})
}
async fn input_events(ctx: &mut TestContext) -> serde_json::Value {
serde_json::from_str(&evaluate_string(ctx, "JSON.stringify(__inputEvents)").await)
.expect("input event log must be JSON")
}
#[tokio::test(flavor = "multi_thread")]
async fn cdp_text_edits_emit_input_event_payloads_for_all_editable_targets() {
for control in CONTROLS {
for command in ["keyDown", "char", "insertText"] {
let mut ctx = keyboard_fixture(control).await;
if command == "insertText" {
ctx.process_async(
json!({"id": 902, "method":"Input.insertText", "params":{"text":"hello"}}),
)
.await;
ctx.expect_result(902, json!({}), None);
} else {
dispatch(&mut ctx, command, "h", "KeyH", "hello").await;
}
assert_eq!(field_value(&mut ctx).await, "hello", "{control}: {command}");
assert_eq!(
input_events(&mut ctx).await,
json!([
expected_input_event("beforeinput", "insertText", json!("hello")),
expected_input_event("input", "insertText", json!("hello")),
]),
"{control}: {command}"
);
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cdp_input_event_data_preserves_utf16_maxlength_edits() {
for control in &CONTROLS[..2] {
let mut ctx = keyboard_fixture(control).await;
evaluate_string(&mut ctx, "(field.maxLength = 3, '')").await;
ctx.process_async(json!({
"id": 902, "method": "Input.insertText", "params": {"text": "😀ab"}
}))
.await;
ctx.expect_result(902, json!({}), None);
assert_eq!(field_value(&mut ctx).await, "😀a");
assert_eq!(
evaluate_string(
&mut ctx,
"JSON.stringify([field.selectionStart,field.selectionEnd])"
)
.await,
"[3,3]"
);
assert_eq!(
input_events(&mut ctx).await,
json!([
expected_input_event("beforeinput", "insertText", json!("😀ab")),
expected_input_event("input", "insertText", json!("😀a")),
])
);
evaluate_string(&mut ctx, "(__inputEvents.length = 0, '')").await;
ctx.process_async(json!({
"id": 902, "method": "Input.insertText", "params": {"text": "z"}
}))
.await;
ctx.expect_result(902, json!({}), None);
assert_eq!(field_value(&mut ctx).await, "😀a");
assert_eq!(
input_events(&mut ctx).await,
json!([expected_input_event(
"beforeinput",
"insertText",
json!("z")
)])
);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cdp_beforeinput_deletion_preserves_utf16_ranges_and_cancellation() {
for control in &CONTROLS[..2] {
for (key, caret, input_type) in [
("Backspace", 3, "deleteContentBackward"),
("Delete", 1, "deleteContentForward"),
] {
let mut ctx = keyboard_fixture(control).await;
evaluate_string(&mut ctx, &format!(
"(() => {{field.value='A😀B'; field.setSelectionRange({caret},{caret}); __cancel='beforeinput'; return '';}})()"
)).await;
dispatch(&mut ctx, "rawKeyDown", key, key, "").await;
assert_eq!(field_value(&mut ctx).await, "A😀B");
assert_eq!(
evaluate_string(
&mut ctx,
"JSON.stringify([field.selectionStart,field.selectionEnd])"
)
.await,
format!("[{caret},{caret}]")
);
assert_eq!(
input_events(&mut ctx).await,
json!([expected_input_event("beforeinput", input_type, json!(null))])
);
evaluate_string(&mut ctx, "(__cancel = '', __inputEvents.length = 0, '')").await;
dispatch(&mut ctx, "rawKeyDown", key, key, "").await;
assert_eq!(field_value(&mut ctx).await, "AB");
assert_eq!(
evaluate_string(
&mut ctx,
"JSON.stringify([field.selectionStart,field.selectionEnd])"
)
.await,
"[1,1]"
);
assert_eq!(
input_events(&mut ctx).await,
json!([
expected_input_event("beforeinput", input_type, json!(null)),
expected_input_event("input", input_type, json!(null)),
])
);
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cdp_text_deletions_have_direction_and_null_data() {
for control in &CONTROLS[..2] {
for (key, input_type, expected) in [
("Backspace", "deleteContentBackward", "ac"),
("Delete", "deleteContentForward", "ab"),
] {
let mut ctx = keyboard_fixture(control).await;
evaluate_string(
&mut ctx,
"(() => {field.value='abc'; field.setSelectionRange(2,2); return '';})()",
)
.await;
dispatch(&mut ctx, "rawKeyDown", key, key, "").await;
assert_eq!(field_value(&mut ctx).await, expected);
assert_eq!(
input_events(&mut ctx).await,
json!([
expected_input_event("beforeinput", input_type, json!(null)),
expected_input_event("input", input_type, json!(null)),
])
);
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cdp_canceling_beforeinput_deletion_preserves_value_and_selection() {
for key in ["Backspace", "Delete"] {
let mut ctx = keyboard_fixture(CONTROLS[0]).await;
evaluate_string(&mut ctx, "(() => {field.value='abc'; field.setSelectionRange(1,1); __cancel='beforeinput'; return '';})()").await;
dispatch(&mut ctx, "rawKeyDown", key, key, "").await;
assert_eq!(
evaluate_string(
&mut ctx,
"JSON.stringify([field.value,field.selectionStart,field.selectionEnd])"
)
.await,
r#"["abc",1,1]"#
);
assert_eq!(input_events(&mut ctx).await.as_array().unwrap().len(), 1);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cdp_textarea_enter_emits_line_break_with_null_data() {
for (command, text) in [("keyDown", "\r"), ("char", "\r")] {
let mut ctx = keyboard_fixture(CONTROLS[1]).await;
dispatch(&mut ctx, command, "Enter", "Enter", text).await;
assert_eq!(field_value(&mut ctx).await, "\n");
assert_eq!(
input_events(&mut ctx).await,
json!([
expected_input_event("beforeinput", "insertLineBreak", json!(null)),
expected_input_event("input", "insertLineBreak", json!(null)),
])
);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cdp_text_input_uses_intrinsic_constructor_and_keeps_change_a_plain_event() {
let mut ctx = keyboard_fixture(CONTROLS[0]).await;
evaluate_string(
&mut ctx,
r#"(() => {
const NativeInputEvent = InputEvent;
window.__nativeEvents=[];
for (const type of ['beforeinput','input','change']) field.addEventListener(type, e =>
__nativeEvents.push([e.type, e instanceof NativeInputEvent, e.isTrusted]));
window.InputEvent=function() {throw Error('page constructor must not run');};
// The general fixture's instanceof uses the replaced constructor; this
// test inspects the captured intrinsic instead.
return '';
})()"#,
)
.await;
dispatch(&mut ctx, "keyDown", "a", "KeyA", "a").await;
evaluate_string(&mut ctx, "(other.focus(), '')").await;
assert_eq!(field_value(&mut ctx).await, "a");
assert_eq!(
evaluate_string(&mut ctx, "JSON.stringify(__nativeEvents)").await,
r#"[["beforeinput",true,true],["input",true,true],["change",false,true]]"#
);
}