diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b..85b7f5fa0f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -65,6 +65,7 @@ export default defineConfig({ "**/composer-selection-formatting.spec.ts", "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", + "**/mention-caret-geometry.spec.ts", "**/team-mentions.spec.ts", "**/persistent-agent-audience.spec.ts", "**/relay-reconnect.spec.ts", diff --git a/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.test.mjs b/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.test.mjs new file mode 100644 index 0000000000..0c68f062f2 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.test.mjs @@ -0,0 +1,270 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { Editor } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; +import StarterKit from "@tiptap/starter-kit"; +import { JSDOM } from "jsdom"; + +import { handleMentionBoundaryBeforeInput } from "./mentionBoundaryBeforeInput.ts"; +import { + MentionHighlightExtension, + mentionHighlightKey, +} from "./mentionHighlightExtension.ts"; + +const dom = new JSDOM("", { + pretendToBeVisual: true, + url: "http://localhost", +}); +const editors = new Set(); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + DocumentFragment: dom.window.DocumentFragment, + DOMParser: dom.window.DOMParser, + Element: dom.window.Element, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + getSelection: dom.window.getSelection.bind(dom.window), + HTMLElement: dom.window.HTMLElement, + MutationObserver: dom.window.MutationObserver, + Node: dom.window.Node, + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + window: dom.window, + }); +}); + +afterEach(() => { + for (const editor of editors) editor.destroy(); + editors.clear(); +}); + +after(() => dom.window.close()); + +function paragraphContent(text) { + return text ? [{ type: "text", text }] : []; +} + +function createView(text, options = {}) { + const editor = new Editor({ + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: options.content ?? paragraphContent(text), + }, + ], + }, + element: document.createElement("div"), + extensions: [ + StarterKit.configure({ + heading: false, + link: false, + trailingNode: false, + }), + MentionHighlightExtension, + ], + }); + editors.add(editor); + + const storage = editor.storage.mentionHighlight; + storage.names = []; + storage.agentNames = options.decorated ? ["Reinhold"] : []; + storage.channelNames = []; + editor.view.dispatch(editor.state.tr.setMeta(mentionHighlightKey, true)); + + const doc = editor.state.doc; + const textLength = doc.textContent.length; + const from = options.from ?? 1 + textLength; + const to = options.to ?? from; + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(doc, from, to)), + ); + + let dispatchCount = 0; + const originalDispatch = editor.view.dispatch.bind(editor.view); + editor.view.dispatch = (transaction) => { + dispatchCount += 1; + originalDispatch(transaction); + }; + + return { + dom: editor.view.dom, + domAtPos(position) { + return editor.view.domAtPos(position); + }, + get state() { + return editor.state; + }, + composing: options.composing ?? false, + dispatch(transaction) { + editor.view.dispatch(transaction); + }, + get dispatchCount() { + return dispatchCount; + }, + }; +} + +function createBeforeInput(overrides = {}) { + let prevented = false; + return { + inputType: "insertText", + data: "t", + isComposing: false, + preventDefault() { + prevented = true; + }, + get prevented() { + return prevented; + }, + ...overrides, + }; +} + +test("inserts the first character after a highlighted agent mention through ProseMirror", () => { + const view = createView("@Reinhold ", { decorated: true }); + const event = createBeforeInput(); + + assert.equal(handleMentionBoundaryBeforeInput(view, event), true); + assert.equal(event.prevented, true); + assert.equal(view.dispatchCount, 1); + assert.equal(view.state.doc.textContent, "@Reinhold t"); + assert.equal( + view.state.doc.textContent.codePointAt("@Reinhold".length), + 0x20, + ); + assert.equal(view.state.selection.from, 1 + "@Reinhold t".length); +}); + +test("maps the caret after the separator outside the agent chip DOM", () => { + const view = createView("@Reinhold ", { decorated: true }); + const label = view.dom.querySelector(".agent-mention-highlight"); + assert.ok(label); + + const separator = label.nextSibling; + assert.equal(separator?.nodeType, Node.TEXT_NODE); + assert.equal(separator?.textContent, " "); + assert.equal(view.state.selection.from, 1 + "@Reinhold ".length); + + const mappedCaret = view.domAtPos(view.state.selection.from); + assert.ok( + (mappedCaret.node === separator && mappedCaret.offset === 1) || + (mappedCaret.node === separator?.parentNode && + mappedCaret.offset === + Array.prototype.indexOf.call( + separator.parentNode.childNodes, + separator, + ) + + 1), + ); +}); + +test("leaves ordinary typing outside an agent-mention boundary to the browser", () => { + const view = createView("ordinary "); + const event = createBeforeInput(); + + assert.equal(handleMentionBoundaryBeforeInput(view, event), false); + assert.equal(event.prevented, false); + assert.equal(view.dispatchCount, 0); +}); + +test("requires a collapsed selection after a U+0020 separator", () => { + const selectedView = createView("@Reinhold ", { from: 1, to: 2 }); + const selectedEvent = createBeforeInput(); + assert.equal( + handleMentionBoundaryBeforeInput(selectedView, selectedEvent), + false, + ); + + const noSpaceView = createView("@Reinhold"); + const noSpaceEvent = createBeforeInput(); + assert.equal( + handleMentionBoundaryBeforeInput(noSpaceView, noSpaceEvent), + false, + ); +}); + +test("does not intercept an unhighlighted mention", () => { + const view = createView("@Reinhold "); + const event = createBeforeInput(); + + assert.equal(handleMentionBoundaryBeforeInput(view, event), false); + assert.equal(event.prevented, false); + assert.equal(view.dispatchCount, 0); +}); + +test("does not infer an agent mention from marked text without a decoration", () => { + const view = createView("@Reinhold ", { + content: [ + { type: "text", marks: [{ type: "bold" }], text: "@Reinhold" }, + { type: "text", text: " " }, + ], + }); + const event = createBeforeInput(); + + assert.equal(handleMentionBoundaryBeforeInput(view, event), false); + assert.equal(event.prevented, false); + assert.equal(view.dispatchCount, 0); +}); + +test("accepts marked text when the exact mention range is decorated", () => { + const view = createView("@Reinhold ", { + decorated: true, + content: [ + { type: "text", marks: [{ type: "bold" }], text: "@Reinhold" }, + { type: "text", text: " " }, + ], + }); + const event = createBeforeInput(); + + assert.equal(handleMentionBoundaryBeforeInput(view, event), true); + assert.equal(event.prevented, true); + assert.equal(view.dispatchCount, 1); + assert.equal(view.state.doc.textContent, "@Reinhold t"); +}); + +test("does not infer an agent mention across split text nodes", () => { + const view = createView("@Reinhold ", { + decorated: true, + content: [ + { type: "text", marks: [{ type: "bold" }], text: "@Rein" }, + { type: "text", text: "hold " }, + ], + }); + const event = createBeforeInput(); + + assert.equal(handleMentionBoundaryBeforeInput(view, event), false); + assert.equal(event.prevented, false); + assert.equal(view.dispatchCount, 0); +}); + +test("does not intercept paste or replacement input", () => { + for (const inputType of ["insertFromPaste", "insertReplacementText"]) { + const view = createView("@Reinhold ", { decorated: true }); + const event = createBeforeInput({ inputType }); + assert.equal(handleMentionBoundaryBeforeInput(view, event), false); + assert.equal(event.prevented, false); + assert.equal(view.dispatchCount, 0); + } +}); + +test("does not intercept composition or IME input", () => { + const composingEventView = createView("@Reinhold ", { decorated: true }); + const composingEvent = createBeforeInput({ isComposing: true }); + assert.equal( + handleMentionBoundaryBeforeInput(composingEventView, composingEvent), + false, + ); + + const composingView = createView("@Reinhold ", { + composing: true, + decorated: true, + }); + const commitEvent = createBeforeInput(); + assert.equal( + handleMentionBoundaryBeforeInput(composingView, commitEvent), + false, + ); +}); diff --git a/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.ts b/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.ts new file mode 100644 index 0000000000..182fb63220 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.ts @@ -0,0 +1,57 @@ +import { TextSelection } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; + +import { findAgentMentionDecorationEndingAt } from "./mentionHighlightExtension"; + +/** + * Bypass native contenteditable mutation for the first ordinary character + * typed after the separator following a highlighted agent mention. + * + * Packaged WebKit can move that character across the decorated mention + * boundary and replace the separator. Dispatching the verified insertion as + * a ProseMirror transaction keeps the document and DOM mutation in agreement. + * Every other beforeinput shape falls through to ProseMirror's normal path. + */ +export function handleMentionBoundaryBeforeInput( + view: EditorView, + event: InputEvent, +): boolean { + const insertedText = event.data; + if ( + event.inputType !== "insertText" || + event.isComposing || + view.composing || + !insertedText || + Array.from(insertedText).length !== 1 || + /[\r\n]/.test(insertedText) + ) { + return false; + } + + const { selection } = view.state; + if (!selection.empty || !selection.$from.parent.inlineContent) return false; + + const { $from, from } = selection; + if ($from.parentOffset < 2) return false; + + const textBeforeCaret = $from.parent.textBetween( + 0, + $from.parentOffset, + "\n", + "\n", + ); + if (!textBeforeCaret.endsWith(" ")) return false; + + const separatorPosition = from - 1; + if (!findAgentMentionDecorationEndingAt(view.state, separatorPosition)) { + return false; + } + + event.preventDefault(); + const transaction = view.state.tr.insertText(insertedText, from, from); + transaction.setSelection( + TextSelection.create(transaction.doc, from + insertedText.length), + ); + view.dispatch(transaction.scrollIntoView()); + return true; +} diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index 1fad414084..c514f7713d 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -1,9 +1,59 @@ import { Extension } from "@tiptap/core"; -import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state"; +import { + Plugin, + PluginKey, + type EditorState, + type Transaction, +} from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; export const mentionHighlightKey = new PluginKey("mentionHighlight"); +type AgentMentionDecorationSpec = { + mentionKind: "agent"; + segment: "prefix" | "label"; +}; + +/** + * Return the authoritative decorated agent-mention range whose final + * character is immediately before `to`, or null when no exact range exists. + * + * Agent mention rendering uses two adjacent decorations so WebKit can hide + * the `@` prefix independently from the visible label. Both segments must be + * present at their exact mapped positions; plain matching text is never + * sufficient. + */ +export function findAgentMentionDecorationEndingAt( + state: EditorState, + to: number, +): { from: number; to: number } | null { + const decorations = mentionHighlightKey.getState(state); + if (!decorations || to <= 1) return null; + + const agentSegments = decorations.find( + 0, + state.doc.content.size, + (spec: AgentMentionDecorationSpec) => spec.mentionKind === "agent", + ); + const label = agentSegments.find( + (decoration: Decoration) => + decoration.to === to && decoration.spec.segment === "label", + ); + if (!label || label.from <= 1) return null; + + const prefixFrom = label.from - 1; + const hasExactPrefix = agentSegments.some( + (decoration: Decoration) => + decoration.from === prefixFrom && + decoration.to === label.from && + decoration.spec.segment === "prefix", + ); + if (!hasExactPrefix) return null; + + if (state.doc.textBetween(prefixFrom, label.from) !== "@") return null; + return { from: prefixFrom, to }; +} + /** * TipTap extension that applies inline `mention-chip` decorations * to `@Name` and `#channel-name` patterns in the document. @@ -305,16 +355,32 @@ function addMatchesForPatterns( const to = from + match[0].length; if (options?.hideMentionPrefix && match[0].startsWith("@")) { decorations.push( - Decoration.inline(from, from + 1, { - class: "agent-mention-at-hidden", - spellcheck: "false", - }), + Decoration.inline( + from, + from + 1, + { + class: "agent-mention-at-hidden", + spellcheck: "false", + }, + { + mentionKind: "agent", + segment: "prefix", + } satisfies AgentMentionDecorationSpec, + ), ); decorations.push( - Decoration.inline(from + 1, to, { - class: className, - spellcheck: "false", - }), + Decoration.inline( + from + 1, + to, + { + class: className, + spellcheck: "false", + }, + { + mentionKind: "agent", + segment: "label", + } satisfies AgentMentionDecorationSpec, + ), ); } else { decorations.push( diff --git a/desktop/src/features/messages/lib/useRichTextEditor.mentionBoundary.test.mjs b/desktop/src/features/messages/lib/useRichTextEditor.mentionBoundary.test.mjs new file mode 100644 index 0000000000..fb6cb35a48 --- /dev/null +++ b/desktop/src/features/messages/lib/useRichTextEditor.mentionBoundary.test.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { TextSelection } from "@tiptap/pm/state"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + pretendToBeVisual: true, + url: "http://localhost", +}); + +let act; +let cleanup; +let renderHook; +let useRichTextEditor; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + DocumentFragment: dom.window.DocumentFragment, + DOMParser: dom.window.DOMParser, + Element: dom.window.Element, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + getSelection: dom.window.getSelection.bind(dom.window), + HTMLElement: dom.window.HTMLElement, + InputEvent: dom.window.InputEvent, + IS_REACT_ACT_ENVIRONMENT: true, + MutationObserver: dom.window.MutationObserver, + Node: dom.window.Node, + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + window: dom.window, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + ({ act, cleanup, renderHook } = await import("@testing-library/react")); + ({ useRichTextEditor } = await import("./useRichTextEditor.ts")); +}); + +afterEach(() => cleanup?.()); +after(() => dom.window.close()); + +function createBeforeInput() { + let prevented = false; + return { + data: "t", + inputType: "insertText", + isComposing: false, + preventDefault() { + prevented = true; + }, + get prevented() { + return prevented; + }, + }; +} + +function setMentionContent(editor, name = "Reinhold") { + editor.commands.setContent({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: `@${name} ` }], + }, + ], + }); + const end = 1 + editor.state.doc.textContent.length; + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, end)), + ); +} + +function invokeProductionBeforeInput(editor, event) { + const handler = editor.view.props.handleDOMEvents?.beforeinput; + assert.equal(typeof handler, "function"); + return handler(editor.view, event); +} + +test("the mounted production beforeinput callback dispatches only at the decorated boundary and uses current agent names", async () => { + const hook = renderHook( + ({ agentMentionNames }) => useRichTextEditor({ agentMentionNames }), + { initialProps: { agentMentionNames: ["Reinhold"] } }, + ); + + assert.ok(hook.result.current.editor); + const editor = hook.result.current.editor; + act(() => setMentionContent(editor)); + + let dispatchCount = 0; + const originalDispatch = editor.view.dispatch.bind(editor.view); + editor.view.dispatch = (transaction) => { + dispatchCount += 1; + originalDispatch(transaction); + }; + + const decoratedEvent = createBeforeInput(); + let handled; + act(() => { + handled = invokeProductionBeforeInput(editor, decoratedEvent); + }); + + assert.equal(handled, true); + assert.equal(decoratedEvent.prevented, true); + assert.equal(dispatchCount, 1); + assert.equal(editor.state.doc.textContent, "@Reinhold t"); + assert.equal( + editor.state.doc.textContent.codePointAt("@Reinhold".length), + 0x20, + ); + assert.equal(editor.state.selection.from, 1 + "@Reinhold t".length); + + act(() => { + hook.rerender({ agentMentionNames: ["Fizz"] }); + }); + act(() => setMentionContent(editor)); + dispatchCount = 0; + + const undecoratedEvent = createBeforeInput(); + act(() => { + handled = invokeProductionBeforeInput(editor, undecoratedEvent); + }); + + assert.equal(handled, false); + assert.equal(undecoratedEvent.prevented, false); + assert.equal(dispatchCount, 0); + assert.equal(editor.state.doc.textContent, "@Reinhold "); + + act(() => setMentionContent(editor, "Fizz")); + dispatchCount = 0; + const refreshedEvent = createBeforeInput(); + act(() => { + handled = invokeProductionBeforeInput(editor, refreshedEvent); + }); + + assert.equal(handled, true); + assert.equal(refreshedEvent.prevented, true); + assert.equal(dispatchCount, 1); + assert.equal(editor.state.doc.textContent, "@Fizz t"); +}); diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index c761081cc3..1aded84c21 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -30,6 +30,7 @@ import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode"; import { useComposerCustomEmoji } from "./useComposerCustomEmoji"; import { buildPlainTextProjection } from "./plainTextProjection"; import { createLinkInteractionExtension } from "./linkInteractionExtension"; +import { handleMentionBoundaryBeforeInput } from "./mentionBoundaryBeforeInput"; import { CodeBlockAfterHardBreak, handleCodeFenceEnter, @@ -494,6 +495,10 @@ export function useRichTextEditor({ "data-testid": "message-input", spellcheck: "true", }, + handleDOMEvents: { + beforeinput: (view, event) => + handleMentionBoundaryBeforeInput(view, event as InputEvent), + }, // ArrowUp in an empty composer → edit your last message (Slack // parity). Handled here in ProseMirror's own DOM `keydown` hook — // NOT via `addKeyboardShortcuts` (the keymap plugin) and NOT via a diff --git a/desktop/tests/e2e/mention-caret-geometry.spec.ts b/desktop/tests/e2e/mention-caret-geometry.spec.ts new file mode 100644 index 0000000000..3aa951629c --- /dev/null +++ b/desktop/tests/e2e/mention-caret-geometry.spec.ts @@ -0,0 +1,152 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +type CaretGeometry = { + caretLeft: number; + caretMeasurement: "collapsed-range" | "marker"; + caretRight: number; + caretWidth: number; + chipRight: number; + selectionAfterSeparator: boolean; + selectionInsideChip: boolean; + spaceLeft: number; + spaceRight: number; + spaceWidth: number; + whiteSpace: string; +}; + +async function measureMentionCaret( + input: import("@playwright/test").Locator, +): Promise { + return input.evaluate((element) => { + const chip = element.querySelector(".agent-mention-highlight"); + if (!(chip instanceof HTMLElement)) { + throw new Error("agent mention chip is missing"); + } + + const separator = chip.nextSibling; + if (!(separator instanceof Text) || separator.data !== " ") { + throw new Error("U+0020 separator is not the chip's text sibling"); + } + + const selection = window.getSelection(); + if (!selection?.isCollapsed || selection.rangeCount !== 1) { + throw new Error("composer selection is not a collapsed caret"); + } + + const separatorIndex = Array.prototype.indexOf.call( + separator.parentNode?.childNodes ?? [], + separator, + ); + const selectionAfterSeparator = + (selection.anchorNode === separator && selection.anchorOffset === 1) || + (selection.anchorNode === separator.parentNode && + selection.anchorOffset === separatorIndex + 1); + + const spaceRange = document.createRange(); + spaceRange.setStart(separator, 0); + spaceRange.setEnd(separator, 1); + + const caretRange = selection.getRangeAt(0).cloneRange(); + const chipRect = chip.getBoundingClientRect(); + const spaceRect = spaceRange.getBoundingClientRect(); + let caretRect = caretRange.getBoundingClientRect(); + let caretMeasurement: CaretGeometry["caretMeasurement"] = "collapsed-range"; + + // Some engines expose an all-zero rectangle for collapsed ranges. Measure + // that same live selection with a zero-width inline marker, then restore + // the separator and caret before returning to the test. + if ( + caretRect.left === 0 && + caretRect.right === 0 && + caretRect.width === 0 + ) { + const marker = document.createElement("span"); + marker.dataset.caretGeometryMarker = ""; + marker.style.display = "inline-block"; + marker.style.width = "0"; + marker.style.height = "1em"; + marker.style.margin = "0"; + marker.style.padding = "0"; + marker.style.border = "0"; + + caretRange.insertNode(marker); + caretRect = marker.getBoundingClientRect(); + caretMeasurement = "marker"; + marker.remove(); + chip.parentNode?.normalize(); + + const restoredSeparator = chip.nextSibling; + if ( + !(restoredSeparator instanceof Text) || + restoredSeparator.data !== " " + ) { + throw new Error( + "failed to restore the U+0020 separator after measurement", + ); + } + const restoredCaret = document.createRange(); + restoredCaret.setStart(restoredSeparator, 1); + restoredCaret.collapse(true); + selection.removeAllRanges(); + selection.addRange(restoredCaret); + } + + return { + caretLeft: caretRect.left, + caretMeasurement, + caretRight: caretRect.right, + caretWidth: caretRect.width, + chipRight: chipRect.right, + selectionAfterSeparator, + selectionInsideChip: chip.contains(selection.anchorNode), + spaceLeft: spaceRect.left, + spaceRight: spaceRect.right, + spaceWidth: spaceRect.width, + whiteSpace: getComputedStyle(element).whiteSpace, + }; + }); +} + +async function selectAgentMention(page: import("@playwright/test").Page) { + await installMockBridge(page, { activePersonaIds: ["builtin:fizz"] }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("@Fi"); + await expect( + page + .getByTestId("message-composer") + .getByTestId("mention-autocomplete") + .getByText("Fizz"), + ).toBeVisible(); + await input.press("Enter"); + await expect(input).toHaveText("@Fizz "); + return input; +} + +// This Chromium smoke test protects the cross-browser composer invariant: +// autocomplete leaves a rendered separator and the caret outside the chip. +// Native packaged-WebKit acceptance remains separate evidence. +test("composer preserves separator geometry after agent mention autocomplete", async ({ + page, +}, testInfo) => { + const input = await selectAgentMention(page); + const geometry = await measureMentionCaret(input); + testInfo.annotations.push({ + type: "geometry", + description: JSON.stringify(geometry), + }); + + expect(geometry.whiteSpace).toBe("break-spaces"); + expect(geometry.selectionAfterSeparator).toBe(true); + expect(geometry.selectionInsideChip).toBe(false); + expect(geometry.spaceWidth).toBeGreaterThan(0); + expect(geometry.spaceLeft).toBeCloseTo(geometry.chipRight, 1); + expect(geometry.caretLeft).toBeCloseTo(geometry.spaceRight, 1); + expect(geometry.caretRight).toBeGreaterThanOrEqual(geometry.chipRight); + expect(geometry.caretWidth).toBeLessThanOrEqual(1); +});