返回 DeepSeek-Reasonix
rich-composer-selection.test.tsx
根目录 / desktop / frontend / src / __tests__ / rich-composer-selection.test.tsx
1 // Run: tsx src/__tests__/rich-composer-selection.test.tsx
2 //
3 // Focused regression suite for rich-composer DOM↔model selection mapping
4 // (issue #6868 caret jump-to-end after skill/plugin invocation tags).
5
6 import { JSDOM } from "jsdom";
7 import React, { useRef, useState } from "react";
8 import { act } from "react";
9 import { createRoot } from "react-dom/client";
10 import {
11 modelFromDom,
12 recoverSelectionAfterEdit,
13 RichComposerInput,
14 selectionFromDom,
15 setDomSelection,
16 slashQueryAt,
17 type RichComposerInputHandle,
18 type RichComposerSelection,
19 } from "../components/RichComposerInput";
20 import { LocaleProvider } from "../lib/i18n";
21 import {
22 commandAvailableAtSlashPosition,
23 type ComposerInvocation,
24 } from "../lib/invocationDisplay";
25 import type { CommandInfo } from "../lib/types";
26
27 let passed = 0;
28 let failed = 0;
29
30 function ok(value: boolean, label: string) {
31 if (value) {
32 process.stdout.write(` PASS ${label}\n`);
33 passed += 1;
34 } else {
35 process.stdout.write(` FAIL ${label}\n`);
36 failed += 1;
37 }
38 }
39
40 function eq(actual: unknown, expected: unknown, label: string) {
41 if (actual === expected) ok(true, label);
42 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
43 }
44
45 function flushTimers(ms = 0): Promise<void> {
46 return new Promise((resolve) => setTimeout(resolve, ms));
47 }
48
49 class TestResizeObserver {
50 observe() {}
51 unobserve() {}
52 disconnect() {}
53 }
54
55 function installDom() {
56 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
57 pretendToBeVisual: true,
58 url: "http://localhost/",
59 });
60 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
61 globalThis.window = dom.window as unknown as Window & typeof globalThis;
62 globalThis.document = dom.window.document;
63 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
64 globalThis.Node = dom.window.Node;
65 globalThis.HTMLElement = dom.window.HTMLElement;
66 globalThis.HTMLDivElement = dom.window.HTMLDivElement;
67 globalThis.HTMLSpanElement = dom.window.HTMLSpanElement;
68 globalThis.HTMLElement = dom.window.HTMLElement;
69 globalThis.Event = dom.window.Event;
70 globalThis.CustomEvent = dom.window.CustomEvent;
71 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
72 globalThis.InputEvent = dom.window.InputEvent;
73 globalThis.MouseEvent = dom.window.MouseEvent;
74 globalThis.MutationObserver = dom.window.MutationObserver;
75 globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => dom.window.setTimeout(() => cb(0), 0) as unknown as number;
76 globalThis.cancelAnimationFrame = (id: number) => dom.window.clearTimeout(id);
77 globalThis.ResizeObserver = TestResizeObserver;
78 return dom;
79 }
80
81 const skillCommand: CommandInfo = {
82 name: "superpowers:writing-plans",
83 description: "Write plans",
84 kind: "skill",
85 plugin: "superpowers",
86 };
87
88 const pluginCommand: CommandInfo = {
89 name: "docs:search",
90 description: "Search docs",
91 kind: "skill",
92 plugin: "docs",
93 };
94
95 const subagentCommand: CommandInfo = {
96 name: "explore",
97 description: "Explore",
98 kind: "subagent",
99 color: "amber",
100 };
101
102 function invocation(id: string, offset: number, command: CommandInfo = skillCommand): ComposerInvocation {
103 return { id, offset, command };
104 }
105
106 function buildWebView2Dom(text: string, invocations: ComposerInvocation[], mode: "sibling" | "anchor" = "sibling") {
107 // Mirror RichComposerInput's render order: text before each invocation offset,
108 // then token + caret-anchor, then any trailing body text after the last token.
109 const root = document.createElement("div");
110 root.className = "composer__rich-input";
111 const ordered = [...invocations].sort((a, b) => a.offset - b.offset || a.id.localeCompare(b.id));
112 let cursor = 0;
113 ordered.forEach((item, index) => {
114 const offset = Math.max(0, Math.min(text.length, item.offset));
115 if (offset > cursor) {
116 root.appendChild(document.createTextNode(text.slice(cursor, offset)));
117 cursor = offset;
118 }
119 const token = document.createElement("span");
120 token.className = "composer-invocation-token";
121 token.contentEditable = "false";
122 token.dataset.invocationId = item.id;
123 token.textContent = item.command.name;
124 root.appendChild(token);
125
126 const anchor = document.createElement("span");
127 anchor.className = "composer-invocation-caret-anchor";
128 anchor.dataset.composerCaretAnchor = "true";
129 const isLast = index === ordered.length - 1;
130 if (mode === "anchor" && isLast && cursor < text.length) {
131 // Windows WebView2 shape: remaining body text lands inside the final caret anchor.
132 anchor.textContent = `\u00A0${text.slice(cursor)}`;
133 cursor = text.length;
134 } else {
135 anchor.textContent = "\u00A0";
136 }
137 root.appendChild(anchor);
138 });
139 if (cursor < text.length) {
140 root.appendChild(document.createTextNode(text.slice(cursor)));
141 }
142 if (ordered.length === 0 && text) {
143 root.appendChild(document.createTextNode(text));
144 }
145 document.body.appendChild(root);
146 return root;
147 }
148
149 function placeCaretInText(root: HTMLElement, logicalOffset: number, afterInvocationId?: string) {
150 setDomSelection(root, { start: logicalOffset, end: logicalOffset, afterInvocationId });
151 }
152
153 function insertTextAtSelection(root: HTMLElement, data: string, known: Map<string, ComposerInvocation>) {
154 const beforeModel = modelFromDom(root, known);
155 const beforeSel = selectionFromDom(root, known);
156 ok(beforeSel.ok, "selection available before insert");
157 if (!beforeSel.ok) return beforeModel;
158
159 const selection = document.getSelection();
160 if (!selection || selection.rangeCount === 0) throw new Error("missing selection");
161 const range = selection.getRangeAt(0);
162 range.deleteContents();
163 const node = document.createTextNode(data);
164 range.insertNode(node);
165 range.setStartAfter(node);
166 range.collapse(true);
167 selection.removeAllRanges();
168 selection.addRange(range);
169
170 const afterModel = modelFromDom(root, known);
171 const afterSel = selectionFromDom(root, known);
172 return { beforeModel, beforeSel: beforeSel.selection, afterModel, afterSel };
173 }
174
175 console.log("\nrich composer slash queries");
176
177 {
178 const start = slashQueryAt("/review", { start: 7, end: 7 });
179 eq(start?.from, 0, "slash query at the start keeps offset zero");
180 eq(start?.query, "review", "slash query at the start captures its filter");
181
182 const middle = slashQueryAt("请用/review检查", { start: 9, end: 9 });
183 eq(middle?.from, 2, "slash query after adjacent Chinese text keeps its real offset");
184 eq(middle?.to, 9, "slash query ends at the middle caret");
185 eq(middle?.query, "review", "slash query after adjacent Chinese text captures its filter");
186
187 const insideToken = slashQueryAt("/review", { start: 4, end: 4 });
188 eq(insideToken?.from, 0, "slash query inside a token starts at its slash");
189 eq(insideToken?.to, 7, "slash query inside a token replaces the full command token");
190 eq(insideToken?.query, "rev", "slash query inside a token filters by text before the caret");
191
192 const end = slashQueryAt("please inspect /", { start: 16, end: 16 });
193 eq(end?.from, 15, "trailing slash opens a query at the end");
194 eq(end?.query, "", "bare trailing slash exposes the full command menu");
195
196 eq(
197 slashQueryAt("@00-基础文件/", { start: 9, end: 9 }),
198 null,
199 "a directory separator inside an active ref does not open slash completion",
200 );
201 const escapedRef = "看 @docs/my\\ dir/rev";
202 eq(
203 slashQueryAt(escapedRef, { start: escapedRef.length, end: escapedRef.length }),
204 null,
205 "a directory separator inside an escaped-space ref does not open slash completion",
206 );
207 const commandAfterRef = "@docs/README.md /review";
208 eq(
209 slashQueryAt(commandAfterRef, { start: commandAfterRef.length, end: commandAfterRef.length })?.query,
210 "review",
211 "slash completion remains available after a completed ref token",
212 );
213
214 eq(
215 slashQueryAt("please /review", { start: 7, end: 14 }),
216 null,
217 "a range selection does not open slash completion",
218 );
219 eq(
220 slashQueryAt("please /review later", { start: 20, end: 20 }),
221 null,
222 "moving beyond the slash token closes completion",
223 );
224 ok(
225 commandAvailableAtSlashPosition(skillCommand, false),
226 "skills remain available away from the message start",
227 );
228 ok(
229 commandAvailableAtSlashPosition(subagentCommand, false),
230 "subagents remain available away from the message start",
231 );
232 ok(
233 !commandAvailableAtSlashPosition({
234 name: "mcp",
235 description: "Manage MCP servers",
236 kind: "builtin",
237 }, false),
238 "builtin commands remain start-only",
239 );
240 }
241
242 console.log("\nrich composer selection mapping");
243
244 {
245 const dom = installDom();
246 const text = "hello world from webview";
247 const inv = [invocation("inv-1", 0)];
248 const known = new Map(inv.map((item) => [item.id, item]));
249
250 // --- Anchor-internal body text (WebView2) ---
251 const anchorRoot = buildWebView2Dom(text, inv, "anchor");
252 const anchorModel = modelFromDom(anchorRoot, known);
253 eq(anchorModel.text, text, "modelFromDom counts user text inside caret anchor");
254 eq(anchorModel.invocations[0]?.offset, 0, "invocation offset stays at zero with anchor-hosted body");
255
256 placeCaretInText(anchorRoot, 5);
257 let read = selectionFromDom(anchorRoot, known);
258 ok(read.ok, "selectionFromDom reads caret inside anchor text");
259 eq(read.ok ? read.selection.start : -1, 5, "caret mid-anchor maps to logical offset 5");
260 eq(read.ok ? read.selection.end : -1, 5, "collapsed mid-anchor selection end matches start");
261
262 // Simulate the pre-fix asymmetry: restore must not jump to end.
263 setDomSelection(anchorRoot, { start: 5, end: 5 });
264 read = selectionFromDom(anchorRoot, known);
265 eq(read.ok ? read.selection.start : -1, 5, "setDomSelection restores caret inside anchor (not end)");
266
267 // First and second character inserts must stay contiguous at the edit point.
268 const first = insertTextAtSelection(anchorRoot, "X", known);
269 if ("afterModel" in first) {
270 eq(first.afterModel.text, "helloX world from webview", "first mid-text insert stays in place (anchor DOM)");
271 eq(first.afterSel.ok ? first.afterSel.selection.start : -1, 6, "caret after first insert is after X");
272 }
273 const second = insertTextAtSelection(anchorRoot, "Y", known);
274 if ("afterModel" in second) {
275 eq(second.afterModel.text, "helloXY world from webview", "second mid-text insert stays contiguous (anchor DOM)");
276 eq(second.afterSel.ok ? second.afterSel.selection.start : -1, 7, "caret after second insert is after Y");
277 }
278 anchorRoot.remove();
279
280 // --- Sibling text node body (browser / non-WebView shape) ---
281 const siblingRoot = buildWebView2Dom(text, inv, "sibling");
282 const siblingModel = modelFromDom(siblingRoot, known);
283 eq(siblingModel.text, text, "modelFromDom counts sibling text after caret anchor");
284 placeCaretInText(siblingRoot, 5);
285 setDomSelection(siblingRoot, { start: 5, end: 5 });
286 read = selectionFromDom(siblingRoot, known);
287 eq(read.ok ? read.selection.start : -1, 5, "setDomSelection restores caret in sibling text node");
288 const siblingInsert = insertTextAtSelection(siblingRoot, "Z", known);
289 if ("afterModel" in siblingInsert) {
290 eq(siblingInsert.afterModel.text, "helloZ world from webview", "mid-text insert works for sibling text DOM shape");
291 }
292 siblingRoot.remove();
293
294 // --- Unavailable selection must not fake text.length ---
295 const lostRoot = buildWebView2Dom("abcdef", inv, "anchor");
296 placeCaretInText(lostRoot, 3);
297 document.getSelection()?.removeAllRanges();
298 const lost = selectionFromDom(lostRoot, known);
299 ok(!lost.ok, "selectionFromDom reports unavailable when selection left the editor");
300 lostRoot.remove();
301
302 // --- beforeinput snapshot recovery ---
303 const recovered = recoverSelectionAfterEdit(
304 {
305 text: "hello world",
306 selection: { start: 5, end: 5 },
307 inputType: "insertText",
308 data: "X",
309 },
310 "helloX world",
311 { start: 11, end: 11 },
312 );
313 eq(recovered.start, 6, "beforeinput insertText recovery keeps caret after inserted char");
314 eq(recovered.end, 6, "beforeinput insertText recovery collapses correctly");
315
316 const recoveredReplace = recoverSelectionAfterEdit(
317 {
318 text: "hello world",
319 selection: { start: 6, end: 11 },
320 inputType: "insertText",
321 data: "there",
322 },
323 "hello there",
324 { start: 11, end: 11 },
325 );
326 eq(recoveredReplace.start, 11, "mid-range replace recovery places caret after replacement");
327
328 const recoveredBackspace = recoverSelectionAfterEdit(
329 {
330 text: "hello world",
331 selection: { start: 5, end: 5 },
332 inputType: "deleteContentBackward",
333 data: null,
334 },
335 "hell world",
336 { start: 11, end: 11 },
337 );
338 eq(recoveredBackspace.start, 4, "Backspace recovery keeps caret at deletion point");
339
340 const recoveredDelete = recoverSelectionAfterEdit(
341 {
342 text: "hello world",
343 selection: { start: 5, end: 5 },
344 inputType: "deleteContentForward",
345 data: null,
346 },
347 "helloworld",
348 { start: 11, end: 11 },
349 );
350 eq(recoveredDelete.start, 5, "Delete recovery keeps caret at deletion point");
351
352 // data=null repeated-character insert: maximal prefix/suffix would jump to end.
353 const recoveredRepeat = recoverSelectionAfterEdit(
354 {
355 text: "aaa",
356 selection: { start: 1, end: 1 },
357 inputType: "insertText",
358 data: null,
359 },
360 "aaaa",
361 { start: 3, end: 3 },
362 );
363 eq(recoveredRepeat.start, 2, "data=null repeated-char insert recovers at edit point (not end)");
364 eq(recoveredRepeat.end, 2, "data=null repeated-char insert collapses at edit point");
365
366 const recoveredRepeatEmptyType = recoverSelectionAfterEdit(
367 {
368 text: "aaa",
369 selection: { start: 1, end: 1 },
370 inputType: "",
371 data: null,
372 },
373 "aaaa",
374 { start: 4, end: 4 },
375 );
376 eq(recoveredRepeatEmptyType.start, 2, "empty inputType + data=null still anchors repeated insert");
377
378 const recoveredReplacementNull = recoverSelectionAfterEdit(
379 {
380 text: "aaa bbb",
381 selection: { start: 0, end: 3 },
382 inputType: "insertReplacementText",
383 data: null,
384 },
385 "aaaa bbb",
386 { start: 7, end: 7 },
387 );
388 eq(recoveredReplacementNull.start, 4, "data=null replacement recovers after the replaced span");
389
390 const recoveredCompositionCommit = recoverSelectionAfterEdit(
391 {
392 text: "hello world",
393 selection: { start: 5, end: 5 },
394 inputType: "insertCompositionText",
395 data: null,
396 },
397 "hello你好 world",
398 { start: 5, end: 5 },
399 );
400 eq(recoveredCompositionCommit.start, 7, "composition commit with data=null places caret after new text");
401 eq(recoveredCompositionCommit.end, 7, "composition commit recovery collapses after new text");
402
403 // --- Multiline + BR ---
404 const multi = document.createElement("div");
405 multi.appendChild(document.createTextNode("line1"));
406 multi.appendChild(document.createElement("br"));
407 multi.appendChild(document.createTextNode("line2"));
408 document.body.appendChild(multi);
409 const multiModel = modelFromDom(multi, new Map());
410 eq(multiModel.text, "line1\nline2", "BR counts as a single newline in the model");
411 setDomSelection(multi, { start: 6, end: 6 });
412 read = selectionFromDom(multi, new Map());
413 eq(read.ok ? read.selection.start : -1, 6, "caret restores after newline (start of line2)");
414 multi.remove();
415
416 // --- Repeated characters and CJK ---
417 const cjkText = "测试测试重复重复";
418 const cjkRoot = buildWebView2Dom(cjkText, inv, "anchor");
419 const cjkKnown = known;
420 placeCaretInText(cjkRoot, 4);
421 setDomSelection(cjkRoot, { start: 4, end: 4 });
422 read = selectionFromDom(cjkRoot, cjkKnown);
423 eq(read.ok ? read.selection.start : -1, 4, "CJK mid-text caret restores without jumping");
424 const cjkInsert = insertTextAtSelection(cjkRoot, "中", cjkKnown);
425 if ("afterModel" in cjkInsert) {
426 eq(cjkInsert.afterModel.text, "测试测试中重复重复", "CJK insert stays at mid-text position");
427 }
428 cjkRoot.remove();
429
430 // --- afterInvocationId / multi-invocation offsets ---
431 const multiInvText = "alpha beta";
432 const multiInv = [
433 invocation("a", 0, skillCommand),
434 invocation("b", 0, pluginCommand),
435 invocation("c", 6, subagentCommand),
436 ];
437 const multiKnown = new Map(multiInv.map((item) => [item.id, item]));
438 const multiRoot = buildWebView2Dom(multiInvText, multiInv, "sibling");
439 const multiModel2 = modelFromDom(multiRoot, multiKnown);
440 eq(multiModel2.text, multiInvText, "multi-invocation model text ignores tokens and sentinels");
441 eq(multiModel2.invocations.map((item) => item.id).join(","), "a,b,c", "multi-invocation order preserved");
442
443 setDomSelection(multiRoot, { start: 0, end: 0, afterInvocationId: "a" });
444 read = selectionFromDom(multiRoot, multiKnown);
445 eq(read.ok ? read.selection.afterInvocationId : undefined, "a", "afterInvocationId restores to first same-offset tag");
446
447 setDomSelection(multiRoot, { start: 0, end: 0, afterInvocationId: "b" });
448 read = selectionFromDom(multiRoot, multiKnown);
449 eq(read.ok ? read.selection.afterInvocationId : undefined, "b", "afterInvocationId distinguishes second same-offset tag");
450
451 setDomSelection(multiRoot, { start: 6, end: 6, afterInvocationId: "c" });
452 read = selectionFromDom(multiRoot, multiKnown);
453 eq(read.ok ? read.selection.start : -1, 6, "caret after mid-text invocation maps to offset 6");
454 eq(read.ok ? read.selection.afterInvocationId : undefined, "c", "afterInvocationId works between body segments");
455
456 setDomSelection(multiRoot, { start: 3, end: 3 });
457 read = selectionFromDom(multiRoot, multiKnown);
458 eq(read.ok ? read.selection.start : -1, 3, "caret before mid-text invocation stays in leading body");
459 multiRoot.remove();
460
461 // --- Non-collapsed range restore ---
462 const rangeRoot = buildWebView2Dom("abcdefghij", inv, "anchor");
463 setDomSelection(rangeRoot, { start: 2, end: 6 });
464 read = selectionFromDom(rangeRoot, known);
465 eq(read.ok ? read.selection.start : -1, 2, "range restore keeps selection start");
466 eq(read.ok ? read.selection.end : -1, 6, "range restore keeps selection end (not collapsed to end-only)");
467 rangeRoot.remove();
468
469 // --- Offset clamping ---
470 const clampRoot = buildWebView2Dom("abc", inv, "sibling");
471 setDomSelection(clampRoot, { start: 99, end: 99 });
472 read = selectionFromDom(clampRoot, known);
473 eq(read.ok ? read.selection.start : -1, 3, "out-of-range offset clamps to nearest valid end");
474 setDomSelection(clampRoot, { start: -5, end: -5 });
475 read = selectionFromDom(clampRoot, known);
476 eq(read.ok ? read.selection.start : -1, 0, "negative offset clamps to start");
477 clampRoot.remove();
478
479 dom.window.close();
480 }
481
482 console.log("\nrich composer selection component integration");
483
484 function Harness({
485 initialText,
486 initialInvocations,
487 onReady,
488 }: {
489 initialText: string;
490 initialInvocations: ComposerInvocation[];
491 onReady: (api: {
492 handle: RichComposerInputHandle | null;
493 getState: () => { text: string; selection: RichComposerSelection; changeCount: number };
494 }) => void;
495 }) {
496 const [text, setText] = useState(initialText);
497 const [invocations, setInvocations] = useState(initialInvocations);
498 const [selection, setSelection] = useState<RichComposerSelection>({ start: 0, end: 0 });
499 const changeCount = useRef(0);
500 const handleRef = useRef<RichComposerInputHandle>(null);
501 const readyOnce = useRef(false);
502
503 if (!readyOnce.current) {
504 readyOnce.current = true;
505 queueMicrotask(() => {
506 onReady({
507 handle: handleRef.current,
508 getState: () => ({ text, selection, changeCount: changeCount.current }),
509 });
510 });
511 }
512
513 return (
514 <LocaleProvider>
515 <RichComposerInput
516 ref={handleRef}
517 text={text}
518 invocations={invocations}
519 placeholder="Message"
520 disabled={false}
521 onChange={(nextText, nextInvocations) => {
522 changeCount.current += 1;
523 setText(nextText);
524 setInvocations(nextInvocations);
525 }}
526 onSelectionChange={(next) => setSelection(next)}
527 onKeyDown={() => {}}
528 onPaste={() => {}}
529 onCompositionStart={() => {}}
530 onCompositionEnd={() => {}}
531 />
532 </LocaleProvider>
533 );
534 }
535
536 {
537 const dom = installDom();
538 const rootEl = document.getElementById("root");
539 if (!rootEl) throw new Error("missing root");
540 const root = createRoot(rootEl);
541
542 const longText = "The quick brown fox jumps over the lazy dog";
543 const inv = [invocation("skill-1", 0, pluginCommand)];
544 let api: {
545 handle: RichComposerInputHandle | null;
546 getState: () => { text: string; selection: RichComposerSelection; changeCount: number };
547 } | null = null;
548
549 await act(async () => {
550 root.render(
551 <Harness
552 initialText={longText}
553 initialInvocations={inv}
554 onReady={(value) => {
555 api = value;
556 }}
557 />,
558 );
559 await flushTimers();
560 });
561
562 const input = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
563 ok(input !== null, "rich composer mounts");
564 if (!input) throw new Error("rich input missing");
565
566 // Force WebView2-like DOM: move body text into the caret anchor after the sentinel.
567 const anchor = input.querySelector<HTMLElement>("[data-composer-caret-anchor]");
568 ok(anchor !== null, "caret anchor is present");
569 if (anchor) {
570 // React rendered: [token][anchor:NBSP][text node sibling]. Move sibling into anchor.
571 const siblingText = Array.from(input.childNodes).find(
572 (node) => node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").length > 0,
573 );
574 if (siblingText) {
575 anchor.textContent = `\u00A0${siblingText.textContent ?? ""}`;
576 siblingText.parentNode?.removeChild(siblingText);
577 }
578 }
579
580 const known = new Map(inv.map((item) => [item.id, item]));
581 eq(modelFromDom(input, known).text, longText, "component DOM still maps after WebView2-like rewrite");
582
583 // Place caret in the middle and type two characters via input events with a
584 // temporary selection blackout between beforeinput and input (WebView2).
585 const mid = 10;
586 await act(async () => {
587 input.focus();
588 setDomSelection(input, { start: mid, end: mid });
589 await flushTimers();
590 });
591
592 const typeChar = async (ch: string) => {
593 const beforeText = modelFromDom(input, known).text;
594 const beforeSel = selectionFromDom(input, known);
595 ok(beforeSel.ok, `selection live before typing ${ch}`);
596 const start = beforeSel.ok ? beforeSel.selection.start : mid;
597 const end = beforeSel.ok ? beforeSel.selection.end : mid;
598
599 await act(async () => {
600 input.dispatchEvent(new window.InputEvent("beforeinput", {
601 bubbles: true,
602 cancelable: true,
603 inputType: "insertText",
604 data: ch,
605 }));
606
607 // Apply the DOM mutation as the browser would.
608 const selection = document.getSelection();
609 if (selection && selection.rangeCount > 0) {
610 const range = selection.getRangeAt(0);
611 range.deleteContents();
612 const node = document.createTextNode(ch);
613 range.insertNode(node);
614 range.setStartAfter(node);
615 range.collapse(true);
616 selection.removeAllRanges();
617 selection.addRange(range);
618 } else {
619 // Fallback: splice into anchor text.
620 if (anchor) {
621 const raw = anchor.textContent ?? "";
622 const logical = raw.startsWith("\u00A0") ? raw.slice(1) : raw;
623 const next = logical.slice(0, start) + ch + logical.slice(end);
624 anchor.textContent = `\u00A0${next}`;
625 }
626 }
627
628 // WebView2 blackout: selection temporarily leaves the editor.
629 document.getSelection()?.removeAllRanges();
630
631 input.dispatchEvent(new window.InputEvent("input", {
632 bubbles: true,
633 data: ch,
634 inputType: "insertText",
635 }));
636 await flushTimers();
637 });
638
639 // After React controlled echo, re-query the live rich input.
640 const live = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
641 if (!live) throw new Error("rich input disappeared after typing");
642 const afterModel = modelFromDom(live, known);
643 const expected = beforeText.slice(0, start) + ch + beforeText.slice(end);
644 eq(afterModel.text, expected, `typed ${JSON.stringify(ch)} stays at mid-text (not appended at end)`);
645 return live;
646 };
647
648 let liveInput = await typeChar("1");
649 liveInput = await typeChar("2");
650
651 // Re-bind known after potential re-render; invocations unchanged.
652 const afterTwo = modelFromDom(liveInput, known);
653 ok(afterTwo.text.includes("1") && afterTwo.text.includes("2"), "both typed characters remain in the body");
654 ok(
655 afterTwo.text.indexOf("12") === mid || afterTwo.text.includes("12"),
656 "typed characters remain contiguous at the original caret",
657 );
658
659 // IME composition: during composition, selection must not be reset via removeAllRanges
660 // from our sync path. We assert that compositionstart suppresses input sync and
661 // compositionend performs a single model update.
662 const beforeImeCount = api?.getState().changeCount ?? 0;
663 const imeInsertAt = Math.min(10, afterTwo.text.length);
664 const textBeforeIme = afterTwo.text;
665 await act(async () => {
666 liveInput.focus();
667 setDomSelection(liveInput, { start: imeInsertAt, end: imeInsertAt });
668 liveInput.dispatchEvent(new Event("compositionstart", { bubbles: true }));
669 // Intermediate composition input with isComposing should not sync.
670 // Apply provisional text at the caret without syncing.
671 const liveSel = document.getSelection();
672 if (liveSel && liveSel.rangeCount > 0) {
673 const range = liveSel.getRangeAt(0);
674 range.insertNode(document.createTextNode("ni"));
675 } else {
676 liveInput.appendChild(document.createTextNode("ni"));
677 }
678 liveInput.dispatchEvent(new window.InputEvent("input", {
679 bubbles: true,
680 data: "ni",
681 inputType: "insertCompositionText",
682 isComposing: true,
683 }));
684 await flushTimers();
685 });
686 const midImeCount = api?.getState().changeCount ?? 0;
687 eq(midImeCount, beforeImeCount, "IME composition intermediate input does not sync the model");
688
689 await act(async () => {
690 // Commit composition: replace provisional "ni" with "你", then black out selection
691 // before compositionend (WebView2 may drop selection across the commit).
692 const current = document.querySelector(".composer__rich-input") as HTMLDivElement;
693 const textNodes: Text[] = [];
694 const collect = (node: Node) => {
695 if (node.nodeType === Node.TEXT_NODE) textNodes.push(node as Text);
696 node.childNodes.forEach((child) => collect(child));
697 };
698 collect(current);
699 const provisional = textNodes.find((node) => (node.textContent ?? "").includes("ni"));
700 if (provisional && provisional.textContent === "ni") {
701 provisional.textContent = "你";
702 } else if (provisional?.textContent?.includes("ni")) {
703 provisional.textContent = provisional.textContent.replace("ni", "你");
704 } else {
705 const anchorEl = current.querySelector<HTMLElement>("[data-composer-caret-anchor]");
706 if (anchorEl) {
707 anchorEl.textContent = `\u00A0${textBeforeIme.slice(0, imeInsertAt)}你${textBeforeIme.slice(imeInsertAt)}`;
708 } else {
709 current.appendChild(document.createTextNode("你"));
710 }
711 }
712 document.getSelection()?.removeAllRanges();
713 current.dispatchEvent(new Event("compositionend", { bubbles: true }));
714 await flushTimers();
715 });
716 const afterImeCount = api?.getState().changeCount ?? 0;
717 ok(afterImeCount === beforeImeCount + 1, "compositionend performs exactly one model sync");
718 const imeLive = document.querySelector(".composer__rich-input") as HTMLDivElement;
719 const imeModel = modelFromDom(imeLive, known);
720 ok(imeModel.text.includes("你"), "committed IME text is present once");
721 const imeSel = selectionFromDom(imeLive, known);
722 // After blackout recovery, caret must sit after the committed run — never the
723 // pre-composition offset when the commit actually grew the text.
724 if (imeModel.text.length > textBeforeIme.length && imeSel.ok) {
725 ok(
726 imeSel.selection.start > imeInsertAt || imeSel.selection.start === imeModel.text.length,
727 "compositionend selection blackout does not leave caret at pre-composition offset",
728 );
729 }
730
731 // Cancel-style composition: start then end without net change should still only sync once.
732 const cancelBase = api?.getState().changeCount ?? 0;
733 await act(async () => {
734 const current = document.querySelector(".composer__rich-input") as HTMLDivElement;
735 current.dispatchEvent(new Event("compositionstart", { bubbles: true }));
736 current.dispatchEvent(new window.InputEvent("input", {
737 bubbles: true,
738 data: "tmp",
739 inputType: "insertCompositionText",
740 isComposing: true,
741 }));
742 current.dispatchEvent(new Event("compositionend", { bubbles: true }));
743 await flushTimers();
744 });
745 const cancelCount = api?.getState().changeCount ?? 0;
746 ok(cancelCount <= cancelBase + 1, "composition cancel/end does not thrash with multiple syncs");
747
748 // Backspace after invocation tag removes the tag (afterInvocationId path).
749 await act(async () => {
750 root.render(
751 <Harness
752 initialText=""
753 initialInvocations={[invocation("backspace-target", 0, skillCommand)]}
754 onReady={() => {}}
755 />,
756 );
757 await flushTimers();
758 });
759 const emptyRich = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
760 ok(emptyRich !== null, "empty invocation-only rich input mounts");
761 if (emptyRich) {
762 const token = emptyRich.querySelector("[data-invocation-id]");
763 ok(token !== null, "invocation token present for Backspace test");
764 await act(async () => {
765 emptyRich.focus();
766 setDomSelection(emptyRich, { start: 0, end: 0, afterInvocationId: "backspace-target" });
767 emptyRich.dispatchEvent(new window.KeyboardEvent("keydown", {
768 key: "Backspace",
769 bubbles: true,
770 cancelable: true,
771 }));
772 await flushTimers();
773 });
774 // After last invocation removal, parent usually switches away; in harness the
775 // component may remain with zero invocations.
776 const remaining = document.querySelectorAll("[data-invocation-id]").length;
777 eq(remaining, 0, "Backspace after skill tag removes the invocation");
778 }
779
780 // The inline remove button is the pointer/touch-accessible counterpart to
781 // Backspace and must preserve the surrounding task text.
782 let clickRemovalSelection: RichComposerSelection | null = null;
783 let clickRemovalText = "";
784 function ClickRemoveHarness() {
785 const [invocations, setInvocations] = useState([
786 invocation("click-remove-target", 2, skillCommand),
787 ]);
788 return (
789 <LocaleProvider>
790 <RichComposerInput
791 text="task"
792 invocations={invocations}
793 placeholder="Message"
794 disabled={false}
795 onChange={(nextText, nextInvocations, origin) => {
796 clickRemovalText = nextText;
797 clickRemovalSelection = origin.afterSelection;
798 setInvocations(nextInvocations);
799 }}
800 onSelectionChange={() => {}}
801 onKeyDown={() => {}}
802 onPaste={() => {}}
803 onCompositionStart={() => {}}
804 onCompositionEnd={() => {}}
805 />
806 </LocaleProvider>
807 );
808 }
809
810 await act(async () => {
811 root.render(<ClickRemoveHarness />);
812 await flushTimers();
813 });
814 const removeInvocation = document.querySelector(
815 ".composer-invocation-token .invocation-display__remove",
816 ) as HTMLButtonElement | null;
817 ok(removeInvocation !== null, "inline invocation exposes an accessible remove button");
818 await act(async () => {
819 removeInvocation?.click();
820 await flushTimers();
821 });
822 eq(document.querySelectorAll("[data-invocation-id]").length, 0, "clicking remove deletes the invocation");
823 eq(clickRemovalText, "task", "clicking remove preserves surrounding task text");
824 eq(clickRemovalSelection?.start, 2, "clicking remove places the caret at the invocation offset");
825
826 // External draft replacement rebuilds DOM; only explicit pending selection is restored.
827 let externalSel: RichComposerSelection = { start: 0, end: 0 };
828 function ExternalHarness() {
829 const [text, setText] = useState("one");
830 const [invocations, setInvocations] = useState<ComposerInvocation[]>([
831 invocation("ext-1", 0, skillCommand),
832 ]);
833 const handleRef = useRef<RichComposerInputHandle>(null);
834 return (
835 <LocaleProvider>
836 <button
837 type="button"
838 id="external-focus"
839 onClick={() => {
840 /* focus sink */
841 }}
842 >
843 outside
844 </button>
845 <RichComposerInput
846 ref={handleRef}
847 text={text}
848 invocations={invocations}
849 placeholder="Message"
850 disabled={false}
851 onChange={(nextText, nextInvocations) => {
852 setText(nextText);
853 setInvocations(nextInvocations);
854 }}
855 onSelectionChange={(next) => {
856 externalSel = next;
857 }}
858 onKeyDown={() => {}}
859 onPaste={() => {}}
860 onCompositionStart={() => {}}
861 onCompositionEnd={() => {}}
862 />
863 <button
864 type="button"
865 id="replace-draft"
866 onClick={() => {
867 setText("replaced draft body");
868 setInvocations([invocation("ext-1", 0, skillCommand)]);
869 }}
870 >
871 replace
872 </button>
873 <button
874 type="button"
875 id="clear-draft"
876 onClick={() => {
877 setText("");
878 setInvocations([]);
879 }}
880 >
881 clear
882 </button>
883 </LocaleProvider>
884 );
885 }
886
887 await act(async () => {
888 root.render(<ExternalHarness />);
889 await flushTimers();
890 });
891
892 const outside = document.getElementById("external-focus") as HTMLButtonElement;
893 const replaceBtn = document.getElementById("replace-draft") as HTMLButtonElement;
894 const clearBtn = document.getElementById("clear-draft") as HTMLButtonElement;
895 outside.focus();
896 eq(document.activeElement, outside, "focus is on an external control before draft replace");
897 await act(async () => {
898 replaceBtn.click();
899 await flushTimers();
900 });
901 ok(document.activeElement === outside, "external draft replace does not steal focus from other controls");
902 const replaced = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
903 ok(replaced !== null, "rich input remains after external draft replace");
904 if (replaced) {
905 const replacedModel = modelFromDom(
906 replaced,
907 new Map([["ext-1", invocation("ext-1", 0, skillCommand)]]),
908 );
909 eq(replacedModel.text, "replaced draft body", "external draft replace updates model text without duplication");
910 }
911
912 await act(async () => {
913 clearBtn.click();
914 await flushTimers();
915 });
916 // Clearing invocations may unmount rich input in the real Composer; in this
917 // harness the component stays mounted with empty model.
918 const cleared = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
919 if (cleared) {
920 eq(modelFromDom(cleared, new Map()).text, "", "send/clear path can empty the rich composer");
921 }
922 ok(true, "no duplicate body text after external replace and clear");
923
924 // Range replace through controlled replaceRange API.
925 let rangeApi: RichComposerInputHandle | null = null;
926 function RangeHarness() {
927 const [text, setText] = useState("abcdefghij");
928 const [invocations, setInvocations] = useState([invocation("r1", 0, skillCommand)]);
929 const handleRef = useRef<RichComposerInputHandle>(null);
930 return (
931 <LocaleProvider>
932 <RichComposerInput
933 ref={(value) => {
934 handleRef.current = value;
935 rangeApi = value;
936 }}
937 text={text}
938 invocations={invocations}
939 placeholder="Message"
940 disabled={false}
941 onChange={(nextText, nextInvocations) => {
942 setText(nextText);
943 setInvocations(nextInvocations);
944 }}
945 onSelectionChange={() => {}}
946 onKeyDown={() => {}}
947 onPaste={() => {}}
948 onCompositionStart={() => {}}
949 onCompositionEnd={() => {}}
950 />
951 </LocaleProvider>
952 );
953 }
954 await act(async () => {
955 root.render(<RangeHarness />);
956 await flushTimers();
957 });
958 await act(async () => {
959 rangeApi?.replaceRange("XY", 2, 5);
960 await flushTimers();
961 });
962 const rangeInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
963 if (rangeInput) {
964 const model = modelFromDom(rangeInput, new Map([["r1", invocation("r1", 0, skillCommand)]]));
965 eq(model.text, "abXYfghij", "mid-range replace rewrites only the selected span");
966 }
967
968 await act(async () => {
969 root.unmount();
970 });
971 dom.window.close();
972 }
973
974 // Isolated IME blackout case: compositionstart snapshot + compositionend without a
975 // live selection must place the caret after the committed run (not at text end).
976 console.log("\nrich composer IME compositionend blackout");
977
978 {
979 const dom = installDom();
980 const rootEl = document.getElementById("root");
981 if (!rootEl) throw new Error("missing root");
982 const root = createRoot(rootEl);
983 const imeKnown = new Map([["ime-1", invocation("ime-1", 0, skillCommand)]]);
984 let changeCount = 0;
985 let lastSelection: RichComposerSelection = { start: 0, end: 0 };
986
987 function ImeHarness() {
988 const [text, setText] = useState("hello world");
989 const [invocations, setInvocations] = useState([invocation("ime-1", 0, skillCommand)]);
990 return (
991 <LocaleProvider>
992 <RichComposerInput
993 text={text}
994 invocations={invocations}
995 placeholder="Message"
996 disabled={false}
997 onChange={(nextText, nextInvocations) => {
998 changeCount += 1;
999 setText(nextText);
1000 setInvocations(nextInvocations);
1001 }}
1002 onSelectionChange={(next) => {
1003 lastSelection = next;
1004 }}
1005 onKeyDown={() => {}}
1006 onPaste={() => {}}
1007 onCompositionStart={() => {}}
1008 onCompositionEnd={() => {}}
1009 />
1010 </LocaleProvider>
1011 );
1012 }
1013
1014 await act(async () => {
1015 root.render(<ImeHarness />);
1016 await flushTimers();
1017 });
1018
1019 const imeRoot = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
1020 ok(imeRoot !== null, "IME blackout harness mounts");
1021 if (imeRoot) {
1022 await act(async () => {
1023 imeRoot.focus();
1024 setDomSelection(imeRoot, { start: 5, end: 5 });
1025 imeRoot.dispatchEvent(new Event("compositionstart", { bubbles: true }));
1026 const sibling = Array.from(imeRoot.childNodes).find(
1027 (node) => node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").length > 0,
1028 ) as Text | undefined;
1029 const anchorEl = imeRoot.querySelector<HTMLElement>("[data-composer-caret-anchor]");
1030 if (sibling) sibling.textContent = "hello你 world";
1031 else if (anchorEl) anchorEl.textContent = "\u00A0hello你 world";
1032 document.getSelection()?.removeAllRanges();
1033 ok(!selectionFromDom(imeRoot, imeKnown).ok, "selection is unavailable at compositionend");
1034 imeRoot.dispatchEvent(new Event("compositionend", { bubbles: true }));
1035 eq(changeCount, 1, "compositionend syncs immediately when the committed DOM is already visible");
1036 await flushTimers();
1037 });
1038
1039 const afterImeRoot = document.querySelector(".composer__rich-input") as HTMLDivElement;
1040 const afterImeModel = modelFromDom(afterImeRoot, imeKnown);
1041 eq(afterImeModel.text, "hello你 world", "IME mid-text commit keeps text at the composition locus");
1042 const afterImeSel = selectionFromDom(afterImeRoot, imeKnown);
1043 // "hello" (5) + "你" (1) → caret index 6. Must not stay at pre-composition 5
1044 // and must not jump to text.length (12).
1045 eq(
1046 afterImeSel.ok ? afterImeSel.selection.start : lastSelection.start,
1047 6,
1048 "IME compositionend blackout places caret after committed characters, not pre-composition offset",
1049 );
1050 eq(lastSelection.start, 6, "onSelectionChange reports caret after committed IME text");
1051 }
1052
1053 await act(async () => {
1054 root.unmount();
1055 });
1056 dom.window.close();
1057 }
1058
1059 // Windows WebView2 may dispatch compositionend before the committed DOM and a
1060 // final non-composing input are visible. The composer must not publish the stale
1061 // pre-composition model during that gap, or the controlled echo erases the IME
1062 // candidate before the final input can be synchronized.
1063 console.log("\nrich composer IME late final input");
1064
1065 {
1066 const dom = installDom();
1067 const rootEl = document.getElementById("root");
1068 if (!rootEl) throw new Error("missing root");
1069 const root = createRoot(rootEl);
1070 const imeKnown = new Map([["ime-late", invocation("ime-late", 0, skillCommand)]]);
1071 let changeCount = 0;
1072 let latestText = "hello world";
1073 let lastSelection: RichComposerSelection = { start: 0, end: 0 };
1074
1075 function LateImeHarness() {
1076 const [text, setText] = useState("hello world");
1077 const [invocations, setInvocations] = useState([invocation("ime-late", 0, skillCommand)]);
1078 return (
1079 <LocaleProvider>
1080 <RichComposerInput
1081 text={text}
1082 invocations={invocations}
1083 placeholder="Message"
1084 disabled={false}
1085 onChange={(nextText, nextInvocations) => {
1086 changeCount += 1;
1087 latestText = nextText;
1088 setText(nextText);
1089 setInvocations(nextInvocations);
1090 }}
1091 onSelectionChange={(next) => {
1092 lastSelection = next;
1093 }}
1094 onKeyDown={() => {}}
1095 onPaste={() => {}}
1096 onCompositionStart={() => {}}
1097 onCompositionEnd={() => {}}
1098 />
1099 </LocaleProvider>
1100 );
1101 }
1102
1103 await act(async () => {
1104 root.render(<LateImeHarness />);
1105 await flushTimers();
1106 });
1107
1108 const imeRoot = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
1109 ok(imeRoot !== null, "late-input IME harness mounts");
1110 if (imeRoot) {
1111 await act(async () => {
1112 imeRoot.focus();
1113 setDomSelection(imeRoot, { start: 5, end: 5 });
1114 imeRoot.dispatchEvent(new Event("compositionstart", { bubbles: true }));
1115 imeRoot.dispatchEvent(new Event("compositionend", { bubbles: true }));
1116 eq(changeCount, 0, "compositionend waits when the committed DOM is not visible yet");
1117
1118 const sibling = Array.from(imeRoot.childNodes).find(
1119 (node) => node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").includes("hello world"),
1120 ) as Text | undefined;
1121 const anchorEl = imeRoot.querySelector<HTMLElement>("[data-composer-caret-anchor]");
1122 if (sibling) sibling.textContent = "hello你 world";
1123 else if (anchorEl) anchorEl.textContent = "\u00A0hello你 world";
1124 document.getSelection()?.removeAllRanges();
1125 imeRoot.dispatchEvent(new window.InputEvent("input", {
1126 bubbles: true,
1127 data: "你",
1128 inputType: "insertCompositionText",
1129 isComposing: false,
1130 }));
1131 await flushTimers();
1132 });
1133
1134 eq(changeCount, 1, "late final input performs one authoritative IME model sync");
1135 eq(latestText, "hello你 world", "late final input preserves the committed IME candidate");
1136 const afterImeRoot = document.querySelector(".composer__rich-input") as HTMLDivElement;
1137 eq(
1138 modelFromDom(afterImeRoot, imeKnown).text,
1139 "hello你 world",
1140 "controlled echo keeps the late committed IME text",
1141 );
1142 eq(lastSelection.start, 6, "late final input restores the caret after the committed IME text");
1143 }
1144
1145 await act(async () => {
1146 root.unmount();
1147 });
1148 dom.window.close();
1149 }
1150
1151 console.log(`\nrich-composer-selection: ${passed} passed, ${failed} failed`);
1152 if (failed > 0) process.exit(1);
1153
1153 lines Plain Text