mirror of
https://github.com/lexmount/moli.git
synced 2026-09-28 08:01:37 +00:00
fix(input): emit native InputEvent metadata for text editing
This commit is contained in:
@@ -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 兼容性,
|
||||
不能算作新增一个通过的检测站点。
|
||||
@@ -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]]"#
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user