From 58cfcc74dfad0c7288d068a846d182b69fb73c86 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Fri, 25 Sep 2026 15:50:56 +0200 Subject: [PATCH 01/13] feat: apply code-mode grammar protection at rich-text carets Recognize Quill code-block editing DOM and semantic code/literal regions through a shared, uncached adapter resolver. Preserve existing Code-mode grammar semantics and field exclusions without changing settings, predictions, or keyboard handling. Add selection-boundary, formatting-change, iframe, and shadow-root regressions plus real grammar-coordinator integration tests. Document the focused scope and remaining transaction-level safety work. --- docs/automatic-code-context.md | 82 ++++++ .../suggestions/CodeContextResolver.ts | 109 ++++++++ .../suggestions/MeasurementEditingContext.ts | 16 +- tests/CodeContextGrammar.test.ts | 116 ++++++++ tests/CodeContextResolver.test.ts | 254 ++++++++++++++++++ 5 files changed, 564 insertions(+), 13 deletions(-) create mode 100644 docs/automatic-code-context.md create mode 100644 src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts create mode 100644 tests/CodeContextGrammar.test.ts create mode 100644 tests/CodeContextResolver.test.ts diff --git a/docs/automatic-code-context.md b/docs/automatic-code-context.md new file mode 100644 index 000000000..6e54035df --- /dev/null +++ b/docs/automatic-code-context.md @@ -0,0 +1,82 @@ +# Automatic rich-text code protection + +FluentTyper applies the existing Code-mode grammar filter at the current caret, +without changing saved global/site settings or restarting the content runtime. +Moving back into prose restores the configured prose rules on the next grammar +operation. The resolver is synchronous and uncached, so changing a paragraph's +format without changing its text is also recognized. + +## Supported DOM signals + +- Semantic `code`, `pre`, `kbd`, and `samp` ancestors. Literal/preformatted text is + protected even when it is not a programming language. +- Quill 2's editing DOM: `.ql-code-block` and `.ql-code-block-container`, including + empty blocks and syntax-highlighting descendants. No `.ql-editor` ancestor is + required, so the standalone code-block representation works too. +- Existing whole-editor markers: `.monaco-editor`, `.CodeMirror`, `.cm-editor`, + and `.ace_editor`. + +Code blocks elsewhere in a composer do not change the active prose paragraph's +mode. Inline code does not disable adjacent prose text. Monospace fonts, +`spellcheck="false"`, `data-gramm="false"`, generic `.code`/`language-*` classes, +and text that merely resembles a program are not used as code evidence. + +## Selection and eligibility + +`CodeContextResolver.ts` uses the editor's owning document and inspects the +caret's ancestors. For shadow-root editors it supplies the accessible ancestor +roots to `getComposedRanges()` when available, with scoped-selection/ordinary +range fallbacks. It checks that the returned range actually belongs to the +editor rather than treating a re-scoped host position as prose. + +Missing, foreign, non-collapsed, or unavailable selections are unknown. A +parent/child-offset position immediately adjacent to code also has uncertain +formatting affinity; prose-only rules are withheld rather than choosing a +sibling. An ordinary text-node position in adjacent prose remains eligible. + +`MeasurementEditingContext.ts` retains the existing grammar-hint interface and +sensitive/read-only/input-type exclusions. Unknown and protected contexts map to +its existing `protected` hint. This hint is already consumed by the shared +local grammar paths, including Enter's virtual word-boundary processing. + +## Semantics and scope + +This is automatic **grammar protection**, not a new autocomplete mode. The +existing `codeSafe` rule allowlist still applies; an explicitly enabled +`autoBracketClose` remains enabled. Unknown/protected does not introduce a new +"block every extension action" policy. Existing composition, edit eligibility, +and selection-stability guards remain responsible for their respective checks. + +No settings, migrations, permissions, network requests, logging of typed text, +prediction messages, or keyboard interception are added. Suggestions, explicit +snippet acceptance, the early-Tab bridge, and Markdown parsing are unchanged. + +This change does not add final replacement-range validation across inline-code +boundaries, clip grammar context to prose-only spans, or add stale-prediction +region tokens. A caret-local check alone is not a guarantee that every possible +replacement range avoids code. Those broader safeguards need a separate edit- +transaction change and end-to-end validation. Custom model-only code styles and +Google Docs canvas code formatting also need dedicated adapters. + +## Tests + +`tests/CodeContextResolver.test.ts` covers the supplied mixed Quill markup, empty +blocks, semantic/inline code, highlighted descendants, prose restoration, +formatting-only changes, negative heuristics, existing protected controls, +ambiguous boundaries, owning-document selection, composed shadow ranges, and +selection API failures. + +`tests/CodeContextGrammar.test.ts` exercises the real grammar coordinator and +catalog: default-rule protection and restoration, all automatic trigger types, +Enter boundaries, and preservation of the optional code-safe bracket rule. + +Run the focused repository tests with: + +```sh +bun test tests/CodeContextResolver.test.ts tests/CodeContextGrammar.test.ts +``` + +Before marking the draft ready, run the repository checks, unit suite, coverage +registry validation, and smoke/full extension suites on Chrome and Firefox as +specified in `docs/agents/testing.md`. Focused DOM checks are not a substitute +for the complete extension or live Slack/editor validation. diff --git a/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts b/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts new file mode 100644 index 000000000..2b5ef612b --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts @@ -0,0 +1,109 @@ +/** Only semantic markup and verified editing-DOM markers belong here. */ +const CODE_CONTEXT = + "code, pre, kbd, samp, .ql-code-block, .ql-code-block-container, " + + ".monaco-editor, .CodeMirror, .cm-editor, .ace_editor"; +const NON_PROSE_CONTEXT = '[contenteditable="false"], [aria-readonly="true"], [role="spinbutton"]'; + +export type CodeContext = "prose" | "code" | "protected" | "unknown"; + +type SelectionRange = Pick; +type ScopedSelectionRoot = ShadowRoot & { getSelection?: () => Selection | null }; +type ComposedSelection = Selection & { + getComposedRanges?: (options: { shadowRoots: ShadowRoot[] }) => SelectionRange[]; +}; + +function parentAcrossShadowRoot(node: Node): Node | null { + if (node.parentNode) return node.parentNode; + return node.nodeType === 11 && "host" in node ? (node as ShadowRoot).host : null; +} + +/** No page globals, computed styles, text heuristics, or document-wide queries. */ +function ancestorContext(node: Node): CodeContext | null { + let code = false; + for (let current: Node | null = node; current; current = parentAcrossShadowRoot(current)) { + if (current.nodeType !== 1) continue; + const element = current as Element; + if (element.matches(NON_PROSE_CONTEXT)) return "protected"; + if (element.matches(CODE_CONTEXT)) code = true; + } + return code ? "code" : null; +} + +function readSelectionRange(element: HTMLElement): SelectionRange | null { + const docSelection = element.ownerDocument.getSelection() as ComposedSelection | null; + if (!docSelection) return null; + + const roots: ShadowRoot[] = []; + let root = element.getRootNode(); + while (root.nodeType === 11 && "host" in root) { + const shadowRoot = root as ShadowRoot; + roots.push(shadowRoot); + root = shadowRoot.host.getRootNode(); + } + + if (roots.length > 0 && typeof docSelection.getComposedRanges === "function") { + const ranges = docSelection.getComposedRanges({ shadowRoots: roots }); + return ranges.length === 1 ? ranges[0] : null; + } + + const selection = + (roots[0] as ScopedSelectionRoot | undefined)?.getSelection?.() ?? docSelection; + return selection.rangeCount === 1 ? selection.getRangeAt(0) : null; +} + +function boundaryContext(node: Node | undefined, atEnd: boolean): CodeContext | null { + if (!node) return null; + // Inspect only the adjacent boundary, not every descendant of a paragraph. + let edge = node; + while (atEnd ? edge.lastChild : edge.firstChild) { + edge = (atEnd ? edge.lastChild : edge.firstChild) as Node; + } + return ancestorContext(edge); +} + +/** + * Resolve the insertion context on demand. Do not persist this as a site setting: + * one editing host may contain both prose and code, and formatting can change + * without changing its text or emitting an input event. + * + * "code" also covers literal/preformatted content whose whitespace must survive. + * An unresolved selection is not evidence of prose. Callers must retain their + * normal eligibility checks (passwords, readonly controls, composition, etc.). + */ +export function resolveCodeContext(element: HTMLElement): CodeContext { + const hostContext = ancestorContext(element); + if (hostContext) return hostContext; + if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") return "prose"; + if (!element.isContentEditable) return "unknown"; + + try { + const range = readSelectionRange(element); + if ( + !range || + range.startContainer !== range.endContainer || + range.startOffset !== range.endOffset || + !element.contains(range.startContainer) + ) { + return "unknown"; + } + + const context = ancestorContext(range.startContainer); + if (context) return context; + + if (range.startContainer.nodeType === 1) { + const children = range.startContainer.childNodes; + // A parent/child-offset caret adjacent to code has ambiguous formatting + // affinity. Do not guess which sibling the editor will insert into. + if ( + boundaryContext(children[range.startOffset - 1], true) || + boundaryContext(children[range.startOffset], false) + ) { + return "unknown"; + } + } + return "prose"; + } catch { + // Selection APIs can be unavailable or invalid while an editor rebuilds DOM. + return "unknown"; + } +} diff --git a/src/adapters/chrome/content-script/suggestions/MeasurementEditingContext.ts b/src/adapters/chrome/content-script/suggestions/MeasurementEditingContext.ts index c4505d118..c3b39054a 100644 --- a/src/adapters/chrome/content-script/suggestions/MeasurementEditingContext.ts +++ b/src/adapters/chrome/content-script/suggestions/MeasurementEditingContext.ts @@ -1,14 +1,11 @@ import type { GrammarHints } from "@core/domain/grammar/types"; - -const PROTECTED_CONTEXT = - 'code, pre, kbd, samp, [contenteditable="false"], [role="spinbutton"], ' + - ".monaco-editor, .CodeMirror, .cm-editor, .ace_editor"; +import { resolveCodeContext } from "./CodeContextResolver"; /** DOM knowledge stays in the adapter, outside the measurement parser. */ export function measurementEditingContext( element: HTMLElement, ): GrammarHints["measurementContext"] { - if (element.closest(PROTECTED_CONTEXT)) return "protected"; + if (resolveCodeContext(element) !== "prose") return "protected"; if (element.getAttribute("aria-readonly") === "true") return "protected"; if ( /(?:^|\s)(?:current-password|new-password|one-time-code)(?:\s|$)/.test( @@ -23,13 +20,6 @@ export function measurementEditingContext( if (field.disabled || field.readOnly || field.selectionStart !== field.selectionEnd) return "protected"; if (element.tagName === "INPUT" && !["text", "search"].includes(field.type)) return "protected"; - return "prose"; } - if (!element.isContentEditable) return "protected"; - const selection = element.ownerDocument.getSelection(); - if (!selection?.isCollapsed || !selection.anchorNode || !element.contains(selection.anchorNode)) - return "protected"; - const anchor = selection.anchorNode; - const parent = anchor.nodeType === 1 ? (anchor as Element) : anchor.parentElement; - return parent?.closest(PROTECTED_CONTEXT) ? "protected" : "prose"; + return "prose"; } diff --git a/tests/CodeContextGrammar.test.ts b/tests/CodeContextGrammar.test.ts new file mode 100644 index 000000000..7ee6baf77 --- /dev/null +++ b/tests/CodeContextGrammar.test.ts @@ -0,0 +1,116 @@ +import { afterEach, expect, test } from "bun:test"; +import { GRAMMAR_RULE_CATALOG } from "../src/core/domain/grammar/ruleCatalog"; +import { measurementEditingContext } from "../src/adapters/chrome/content-script/suggestions/MeasurementEditingContext"; +import { SuggestionGrammarCoordinator } from "../src/adapters/chrome/content-script/suggestions/SuggestionGrammarCoordinator"; + +function fixture(): { root: HTMLDivElement; prose: Text; code: Text } { + const root = document.createElement("div"); + root.setAttribute("contenteditable", "true"); + Object.defineProperty(root, "isContentEditable", { configurable: true, value: true }); + root.innerHTML = '

teh

teh
'; + document.body.append(root); + return { + root, + prose: root.firstElementChild!.firstChild as Text, + code: root.lastElementChild!.firstChild as Text, + }; +} + +function select(node: Text): void { + const range = document.createRange(); + range.setStart(node, node.length); + range.collapse(true); + document.getSelection()!.removeAllRanges(); + document.getSelection()!.addRange(range); +} + +function coordinator(enabledGrammarRules?: string[]): SuggestionGrammarCoordinator { + return new SuggestionGrammarCoordinator({ + enabledGrammarRules: + enabledGrammarRules ?? + GRAMMAR_RULE_CATALOG.filter((rule) => rule.defaultRollout === "on").map((rule) => rule.id), + insertSpaceAfterAutocomplete: true, + lang: "en_US", + userDictionaryList: [], + }); +} + +afterEach(() => { + document.getSelection()?.removeAllRanges(); + document.body.replaceChildren(); +}); + +test("default grammar protects Quill code and resumes in prose without reconfiguration", () => { + const { root, prose, code } = fixture(); + const grammar = coordinator(); + const run = () => + grammar.run({ + beforeCursor: "teh ", + afterCursor: "", + inputAction: "insert", + triggers: ["insertChar", "wordBoundary"], + measurementContext: measurementEditingContext(root), + }); + select(prose); + expect(run()).not.toBeNull(); + select(code); + expect(run()).toBeNull(); + select(prose); + expect(run()).not.toBeNull(); +}); + +test("all automatic grammar triggers receive code protection from the shared resolver", () => { + const { root, code } = fixture(); + const grammar = coordinator(); + select(code); + for (const trigger of ["insertChar", "wordBoundary", "idle", "paste"] as const) { + expect( + grammar.run({ + beforeCursor: "teh ", + afterCursor: "", + inputAction: "insert", + triggers: [trigger], + measurementContext: measurementEditingContext(root), + }), + ).toBeNull(); + } +}); + +test("Enter virtual word-boundary grammar respects the current code region", () => { + const { root, prose, code } = fixture(); + const grammar = coordinator(["englishTypoWhitelistCorrection"]); + const run = () => + grammar.runVirtualWordBoundary({ + beforeCursor: "teh", + afterCursor: "", + measurementContext: measurementEditingContext(root), + }); + select(prose); + expect(run()).not.toBeNull(); + select(code); + expect(run()).toBeNull(); +}); + +test("automatic protection preserves the existing optional code-safe bracket rule", () => { + const { root, code } = fixture(); + code.textContent = "("; + select(code); + const edit = coordinator(["autoBracketClose"]).run({ + beforeCursor: "(", + afterCursor: "", + inputAction: "insert", + triggers: ["insertChar"], + measurementContext: measurementEditingContext(root), + }); + expect(edit?.sourceRuleId).toBe("autoBracketClose"); + expect(edit?.replacement).toBe("()"); + expect( + coordinator([]).run({ + beforeCursor: "(", + afterCursor: "", + inputAction: "insert", + triggers: ["insertChar"], + measurementContext: measurementEditingContext(root), + }), + ).toBeNull(); +}); diff --git a/tests/CodeContextResolver.test.ts b/tests/CodeContextResolver.test.ts new file mode 100644 index 000000000..0cd0b4344 --- /dev/null +++ b/tests/CodeContextResolver.test.ts @@ -0,0 +1,254 @@ +import { afterEach, expect, jest, test } from "bun:test"; +import { resolveCodeContext } from "../src/adapters/chrome/content-script/suggestions/CodeContextResolver"; +import { measurementEditingContext } from "../src/adapters/chrome/content-script/suggestions/MeasurementEditingContext"; + +function editor(html: string, doc: Document = document): HTMLDivElement { + const element = doc.createElement("div"); + element.setAttribute("contenteditable", "true"); + // jsdom does not implement inherited isContentEditable. + Object.defineProperty(element, "isContentEditable", { configurable: true, value: true }); + element.innerHTML = html; + doc.body.append(element); + return element; +} + +function caret(node: Node, offset = node.textContent?.length ?? 0): void { + const doc = node.ownerDocument ?? document; + const selection = doc.getSelection(); + if (!selection) throw new Error("Missing fixture selection"); + const range = doc.createRange(); + range.setStart(node, offset); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); +} + +function text(element: Element): Text { + const node = element.firstChild; + if (!node || node.nodeType !== 3) throw new Error("Missing fixture text"); + return node as Text; +} + +afterEach(() => { + jest.restoreAllMocks(); + document.getSelection()?.removeAllRanges(); + document.body.replaceChildren(); +}); + +test("switches code protection with the caret in the supplied mixed Quill composer", () => { + const root = editor('

hello

hello

again

'); + root.className = "ql-editor"; + root.setAttribute("data-gramm", "false"); + root.setAttribute("spellcheck", "true"); + const [first, code, last] = Array.from(root.children); + for (const paragraph of [first, last, first]) { + caret(text(paragraph)); + expect(measurementEditingContext(root)).toBe("prose"); + caret(text(code)); + expect(resolveCodeContext(root)).toBe("code"); + expect(measurementEditingContext(root)).toBe("protected"); + } + caret(text(first)); + expect(measurementEditingContext(root)).toBe("prose"); +}); + +for (const markup of [ + '
hello
', + '
hello
', + "
hello
", + "

before hello after

", + "
hello
", + "hello", + "hello", +]) { + test(`protects nested editable code representation: ${markup}`, () => { + const root = editor(markup); + caret(text(root.querySelector("span")!)); + expect(resolveCodeContext(root)).toBe("code"); + expect(measurementEditingContext(root)).toBe("protected"); + }); +} + +test("detects an empty Quill code block before the first character and throughout typing", () => { + const root = editor('

prose


'); + const block = root.lastElementChild!; + caret(block, 0); + expect(measurementEditingContext(root)).toBe("protected"); + for (const value of ["c", "co", "const", "const x=1;"]) { + block.textContent = value; + caret(text(block)); + expect(measurementEditingContext(root)).toBe("protected"); + } +}); + +test("does not inherit code from a sibling of an empty prose paragraph", () => { + const root = editor('
code


'); + caret(root.lastElementChild!, 0); + expect(resolveCodeContext(root)).toBe("prose"); +}); + +test("does not disable prose text next to inline code", () => { + const root = editor("

before hello after

"); + const paragraph = root.firstElementChild!; + caret(paragraph.firstChild!, 3); + expect(measurementEditingContext(root)).toBe("prose"); + caret(paragraph.lastChild!, 0); + expect(measurementEditingContext(root)).toBe("prose"); +}); + +test("defers at ambiguous parent-offset boundaries instead of choosing a code sibling", () => { + const root = editor('

prose

code

prose

'); + for (const offset of [1, 2]) { + caret(root, offset); + expect(resolveCodeContext(root)).toBe("unknown"); + expect(measurementEditingContext(root)).toBe("protected"); + } + root.innerHTML = "

before code after

"; + for (const offset of [1, 2]) { + caret(root.firstElementChild!, offset); + expect(resolveCodeContext(root)).toBe("unknown"); + } +}); + +test("only inspects the adjacent edge of a paragraph when resolving a boundary", () => { + const root = editor("

code prose

more prose

"); + caret(root, 1); + expect(resolveCodeContext(root)).toBe("prose"); +}); + +test("rechecks formatting-only DOM changes without cached text or input events", () => { + const root = editor("
same text
"); + const block = root.firstElementChild!; + caret(text(block)); + expect(measurementEditingContext(root)).toBe("prose"); + block.classList.add("ql-code-block"); + expect(measurementEditingContext(root)).toBe("protected"); + block.classList.remove("ql-code-block"); + expect(measurementEditingContext(root)).toBe("prose"); +}); + +test("does not guess code from typography, spellcheck, generic classes, or text", () => { + const root = editor( + '

x=1;

', + ); + root.setAttribute("spellcheck", "false"); + root.setAttribute("data-gramm", "false"); + caret(text(root.firstElementChild!)); + expect(measurementEditingContext(root)).toBe("prose"); +}); + +for (const className of ["monaco-editor", "CodeMirror", "cm-editor", "ace_editor"]) { + test(`retains whole-field code protection for ${className}`, () => { + const host = document.createElement("div"); + host.className = className; + const input = document.createElement("textarea"); + host.append(input); + document.body.append(host); + expect(resolveCodeContext(input)).toBe("code"); + expect(measurementEditingContext(input)).toBe("protected"); + }); +} + +test("keeps non-code protected contexts distinct from code detection", () => { + for (const attr of ['contenteditable="false"', 'aria-readonly="true"', 'role="spinbutton"']) { + const root = editor(`value`); + caret(text(root.firstElementChild!)); + expect(resolveCodeContext(root)).toBe("protected"); + expect(measurementEditingContext(root)).toBe("protected"); + root.remove(); + } +}); + +test("preserves sensitive, readonly, numeric, and selected text-field exclusions", () => { + const input = document.createElement("input"); + document.body.append(input); + input.value = "hello"; + input.setSelectionRange(5, 5); + expect(measurementEditingContext(input)).toBe("prose"); + for (const [name, value] of [ + ["autocomplete", "section-login current-password"], + ["autocomplete", "new-password"], + ["autocomplete", "one-time-code"], + ["inputmode", "numeric"], + ["aria-readonly", "true"], + ["readonly", ""], + ["disabled", ""], + ["type", "password"], + ["type", "number"], + ]) { + input.setAttribute(name, value); + expect(measurementEditingContext(input)).toBe("protected"); + input.removeAttribute(name); + } + input.value = "hello"; + input.setSelectionRange(1, 3); + expect(measurementEditingContext(input)).toBe("protected"); +}); + +test("fails closed for missing, foreign, and non-collapsed contenteditable selections", () => { + const root = editor("

hello

"); + document.getSelection()?.removeAllRanges(); + expect(resolveCodeContext(root)).toBe("unknown"); + const other = editor("

hello

"); + caret(text(other.firstElementChild!)); + expect(resolveCodeContext(root)).toBe("unknown"); + caret(text(root.firstElementChild!), 1); + document.getSelection()!.extend(text(root.firstElementChild!), 3); + expect(resolveCodeContext(root)).toBe("unknown"); +}); + +test("uses the editor's owning document for iframe selections", () => { + const frame = document.createElement("iframe"); + document.body.append(frame); + const doc = frame.contentDocument!; + const root = editor('

prose

code
', doc); + caret(text(root.firstElementChild!)); + expect(measurementEditingContext(root)).toBe("prose"); + caret(text(root.lastElementChild!)); + expect(measurementEditingContext(root)).toBe("protected"); +}); + +test("supplies nested shadow roots to composed selection resolution", () => { + const host = document.createElement("div"); + document.body.append(host); + const outer = host.attachShadow({ mode: "open" }); + const innerHost = document.createElement("div"); + outer.append(innerHost); + const inner = innerHost.attachShadow({ mode: "open" }); + const root = editor('
code
'); + inner.append(root); + const node = text(root.firstElementChild!); + const getComposedRanges = jest.fn(() => [ + { startContainer: node, startOffset: 2, endContainer: node, endOffset: 2 }, + ]); + jest + .spyOn(document, "getSelection") + .mockReturnValue({ getComposedRanges } as unknown as Selection); + expect(measurementEditingContext(root)).toBe("protected"); + expect(resolveCodeContext(root)).toBe("code"); + expect(getComposedRanges).toHaveBeenCalledWith({ shadowRoots: [inner, outer] }); +}); + +test("does not mistake a shadow-host re-scoped selection for inner prose", () => { + const host = document.createElement("div"); + document.body.append(host); + const shadow = host.attachShadow({ mode: "open" }); + const root = editor("

prose

"); + shadow.append(root); + const range = document.createRange(); + range.selectNode(host); + const getComposedRanges = jest.fn(() => [range]); + jest + .spyOn(document, "getSelection") + .mockReturnValue({ getComposedRanges } as unknown as Selection); + expect(resolveCodeContext(root)).toBe("unknown"); +}); + +test("selection API failure cannot classify unknown context as prose", () => { + const root = editor("

prose

"); + jest.spyOn(document, "getSelection").mockImplementation(() => { + throw new Error("Selection unavailable during reconciliation"); + }); + expect(resolveCodeContext(root)).toBe("unknown"); + expect(measurementEditingContext(root)).toBe("protected"); +}); From c3767f653fa3ecec97b03b5ca3fec7ff1b18b52a Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Fri, 25 Sep 2026 15:53:20 +0200 Subject: [PATCH 02/13] fix: avoid redundant selection assertion with repository DOM types --- .../chrome/content-script/suggestions/CodeContextResolver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts b/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts index 2b5ef612b..051727a0a 100644 --- a/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts +++ b/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts @@ -30,7 +30,7 @@ function ancestorContext(node: Node): CodeContext | null { } function readSelectionRange(element: HTMLElement): SelectionRange | null { - const docSelection = element.ownerDocument.getSelection() as ComposedSelection | null; + const docSelection: ComposedSelection | null = element.ownerDocument.getSelection(); if (!docSelection) return null; const roots: ShadowRoot[] = []; From 6ae6309db0429585dac1e5c491cccbd81816921c Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Fri, 25 Sep 2026 15:59:01 +0200 Subject: [PATCH 03/13] style: align code-context selection expression with Prettier --- .../chrome/content-script/suggestions/CodeContextResolver.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts b/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts index 051727a0a..14c161455 100644 --- a/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts +++ b/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts @@ -46,8 +46,7 @@ function readSelectionRange(element: HTMLElement): SelectionRange | null { return ranges.length === 1 ? ranges[0] : null; } - const selection = - (roots[0] as ScopedSelectionRoot | undefined)?.getSelection?.() ?? docSelection; + const selection = (roots[0] as ScopedSelectionRoot | undefined)?.getSelection?.() ?? docSelection; return selection.rangeCount === 1 ? selection.getRangeAt(0) : null; } From 7fd40460e2c23948c0efdf6654e39a57d0ee9fc0 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Fri, 25 Sep 2026 16:13:47 +0200 Subject: [PATCH 04/13] test: expose composed-selection preconditions in nested shadow fixture --- tests/CodeContextShadow.test.ts | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/CodeContextShadow.test.ts diff --git a/tests/CodeContextShadow.test.ts b/tests/CodeContextShadow.test.ts new file mode 100644 index 000000000..c419242ca --- /dev/null +++ b/tests/CodeContextShadow.test.ts @@ -0,0 +1,39 @@ +import { expect, jest, test } from "bun:test"; +import { resolveCodeContext } from "../src/adapters/chrome/content-script/suggestions/CodeContextResolver"; + +test("nested shadow selection resolves from the supplied composed range", () => { + const host = document.createElement("div"); + document.body.append(host); + const outer = host.attachShadow({ mode: "open" }); + const innerHost = document.createElement("div"); + outer.append(innerHost); + const inner = innerHost.attachShadow({ mode: "open" }); + const editor = document.createElement("div"); + editor.setAttribute("contenteditable", "true"); + Object.defineProperty(editor, "isContentEditable", { value: true }); + editor.innerHTML = '
code
'; + inner.append(editor); + const block = editor.firstElementChild!; + const node = block.firstChild!; + const range = { startContainer: node, startOffset: 2, endContainer: node, endOffset: 2 }; + const getComposedRanges = jest.fn(() => [range]); + const selection = { getComposedRanges } as unknown as Selection; + const spy = jest.spyOn(document, "getSelection").mockReturnValue(selection); + try { + expect(editor.ownerDocument).toBe(document); + expect(editor.getRootNode()).toBe(inner); + expect(inner.host.getRootNode()).toBe(outer); + expect(outer.host.getRootNode()).toBe(document); + expect(editor.ownerDocument.getSelection()).toBe(selection); + expect(editor.contains(node)).toBe(true); + expect(block.matches("code, pre, kbd, samp, .ql-code-block, .ql-code-block-container")).toBe( + true, + ); + const context = resolveCodeContext(editor); + expect(getComposedRanges).toHaveBeenCalledWith({ shadowRoots: [inner, outer] }); + expect(context).toBe("code"); + } finally { + spy.mockRestore(); + host.remove(); + } +}); From 0b0449f0bc51cd7225c7cccb3749a0623e4e1aab Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Fri, 25 Sep 2026 16:19:38 +0200 Subject: [PATCH 05/13] test: isolate code-context failure and retain full-suite diagnostics --- .github/workflows/code-context-audit.yml | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/code-context-audit.yml diff --git a/.github/workflows/code-context-audit.yml b/.github/workflows/code-context-audit.yml new file mode 100644 index 000000000..8cde6ce22 --- /dev/null +++ b/.github/workflows/code-context-audit.yml @@ -0,0 +1,44 @@ +name: Code context audit + +on: + pull_request: + branches: [master] + +permissions: + contents: read + +jobs: + audit: + if: github.event.pull_request.number == 412 + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + - run: bun install --frozen-lockfile + - name: Focused and full-suite comparison + run: | + python3 - <<'PY' + import subprocess + import sys + + commands = [ + ["bun", "test", "tests/CodeContextShadow.test.ts", "tests/CodeContextResolver.test.ts", "tests/CodeContextGrammar.test.ts"], + ["bun", "run", "test"], + ] + failed = False + for command in commands: + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + lines = result.stdout.splitlines() + print("COMMAND:", " ".join(command), "EXIT:", result.returncode) + indices = set(range(max(0, len(lines) - 25), len(lines))) + for index, line in enumerate(lines): + if "(fail)" in line or "error:" in line or "Expected:" in line: + indices.update(range(max(0, index - 18), min(len(lines), index + 12))) + for index in sorted(indices): + print(lines[index]) + failed = failed or result.returncode != 0 + sys.exit(1 if failed else 0) + PY From 7ce10260c84698513d5272fffdca6764d3525cd2 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Fri, 25 Sep 2026 16:22:10 +0200 Subject: [PATCH 06/13] test: fix ineffective selection spies and prove shadow/error branches execute --- tests/CodeContextResolver.test.ts | 54 ++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/tests/CodeContextResolver.test.ts b/tests/CodeContextResolver.test.ts index 0cd0b4344..0a662c8d8 100644 --- a/tests/CodeContextResolver.test.ts +++ b/tests/CodeContextResolver.test.ts @@ -29,6 +29,20 @@ function text(element: Element): Text { return node as Text; } +function withProperty(target: object, name: string, value: unknown, run: () => void): void { + // Instance spies on jsdom's inherited Document methods were ineffective in + // Bun CI. Install an own property and restore its exact previous descriptor. + const previous = Object.getOwnPropertyDescriptor(target, name); + Object.defineProperty(target, name, { configurable: true, writable: true, value }); + try { + expect(Reflect.get(target, name)).toBe(value); + run(); + } finally { + if (previous) Object.defineProperty(target, name, previous); + else Reflect.deleteProperty(target, name); + } +} + afterEach(() => { jest.restoreAllMocks(); document.getSelection()?.removeAllRanges(); @@ -215,18 +229,21 @@ test("supplies nested shadow roots to composed selection resolution", () => { const innerHost = document.createElement("div"); outer.append(innerHost); const inner = innerHost.attachShadow({ mode: "open" }); - const root = editor('
code
'); + const root = editor('

prose

code
'); inner.append(root); - const node = text(root.firstElementChild!); + let node = text(root.lastElementChild!); const getComposedRanges = jest.fn(() => [ { startContainer: node, startOffset: 2, endContainer: node, endOffset: 2 }, ]); - jest - .spyOn(document, "getSelection") - .mockReturnValue({ getComposedRanges } as unknown as Selection); - expect(measurementEditingContext(root)).toBe("protected"); - expect(resolveCodeContext(root)).toBe("code"); - expect(getComposedRanges).toHaveBeenCalledWith({ shadowRoots: [inner, outer] }); + withProperty(document.getSelection()!, "getComposedRanges", getComposedRanges, () => { + expect(resolveCodeContext(root)).toBe("code"); + expect(measurementEditingContext(root)).toBe("protected"); + node = text(root.firstElementChild!); + expect(resolveCodeContext(root)).toBe("prose"); + expect(measurementEditingContext(root)).toBe("prose"); + expect(getComposedRanges).toHaveBeenCalledTimes(4); + expect(getComposedRanges).toHaveBeenCalledWith({ shadowRoots: [inner, outer] }); + }); }); test("does not mistake a shadow-host re-scoped selection for inner prose", () => { @@ -237,18 +254,25 @@ test("does not mistake a shadow-host re-scoped selection for inner prose", () => shadow.append(root); const range = document.createRange(); range.selectNode(host); + range.collapse(true); const getComposedRanges = jest.fn(() => [range]); - jest - .spyOn(document, "getSelection") - .mockReturnValue({ getComposedRanges } as unknown as Selection); - expect(resolveCodeContext(root)).toBe("unknown"); + withProperty(document.getSelection()!, "getComposedRanges", getComposedRanges, () => { + expect(resolveCodeContext(root)).toBe("unknown"); + expect(getComposedRanges).toHaveBeenCalledWith({ shadowRoots: [shadow] }); + }); }); test("selection API failure cannot classify unknown context as prose", () => { const root = editor("

prose

"); - jest.spyOn(document, "getSelection").mockImplementation(() => { + caret(text(root.firstElementChild!)); + expect(resolveCodeContext(root)).toBe("prose"); + const getSelection = jest.fn(() => { throw new Error("Selection unavailable during reconciliation"); }); - expect(resolveCodeContext(root)).toBe("unknown"); - expect(measurementEditingContext(root)).toBe("protected"); + withProperty(document, "getSelection", getSelection, () => { + expect(resolveCodeContext(root)).toBe("unknown"); + expect(measurementEditingContext(root)).toBe("protected"); + expect(getSelection).toHaveBeenCalledTimes(2); + }); + expect(resolveCodeContext(root)).toBe("prose"); }); From 2e8548f851832dabc6be2f2cf33562f1091e3a5f Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Fri, 25 Sep 2026 16:23:03 +0200 Subject: [PATCH 07/13] test: cover shadow selection fallbacks and reject ambiguous composed ranges --- tests/CodeContextShadow.test.ts | 125 ++++++++++++++++++++++++-------- 1 file changed, 93 insertions(+), 32 deletions(-) diff --git a/tests/CodeContextShadow.test.ts b/tests/CodeContextShadow.test.ts index c419242ca..88ca8b56d 100644 --- a/tests/CodeContextShadow.test.ts +++ b/tests/CodeContextShadow.test.ts @@ -1,39 +1,100 @@ import { expect, jest, test } from "bun:test"; import { resolveCodeContext } from "../src/adapters/chrome/content-script/suggestions/CodeContextResolver"; -test("nested shadow selection resolves from the supplied composed range", () => { - const host = document.createElement("div"); - document.body.append(host); - const outer = host.attachShadow({ mode: "open" }); - const innerHost = document.createElement("div"); - outer.append(innerHost); - const inner = innerHost.attachShadow({ mode: "open" }); - const editor = document.createElement("div"); - editor.setAttribute("contenteditable", "true"); - Object.defineProperty(editor, "isContentEditable", { value: true }); - editor.innerHTML = '
code
'; - inner.append(editor); - const block = editor.firstElementChild!; - const node = block.firstChild!; - const range = { startContainer: node, startOffset: 2, endContainer: node, endOffset: 2 }; - const getComposedRanges = jest.fn(() => [range]); - const selection = { getComposedRanges } as unknown as Selection; - const spy = jest.spyOn(document, "getSelection").mockReturnValue(selection); +type CaretRange = Pick; + +function withProperty(target: object, name: string, value: unknown, run: () => void): void { + const previous = Object.getOwnPropertyDescriptor(target, name); + Object.defineProperty(target, name, { configurable: true, writable: true, value }); try { - expect(editor.ownerDocument).toBe(document); - expect(editor.getRootNode()).toBe(inner); - expect(inner.host.getRootNode()).toBe(outer); - expect(outer.host.getRootNode()).toBe(document); - expect(editor.ownerDocument.getSelection()).toBe(selection); - expect(editor.contains(node)).toBe(true); - expect(block.matches("code, pre, kbd, samp, .ql-code-block, .ql-code-block-container")).toBe( - true, - ); - const context = resolveCodeContext(editor); - expect(getComposedRanges).toHaveBeenCalledWith({ shadowRoots: [inner, outer] }); - expect(context).toBe("code"); + expect(Reflect.get(target, name)).toBe(value); + run(); } finally { - spy.mockRestore(); - host.remove(); + if (previous) Object.defineProperty(target, name, previous); + else Reflect.deleteProperty(target, name); + } +} + +function fixture() { + const host = document.createElement("div"); + document.body.append(host); + const shadow = host.attachShadow({ mode: "open" }); + const root = document.createElement("div"); + root.setAttribute("contenteditable", "true"); + Object.defineProperty(root, "isContentEditable", { value: true }); + root.innerHTML = '

prose

code
'; + shadow.append(root); + const prose = root.firstElementChild!.firstChild!; + const code = root.lastElementChild!.firstChild!; + const at = (node: Node): CaretRange => ({ + startContainer: node, + startOffset: 2, + endContainer: node, + endOffset: 2, + }); + return { root, shadow, prose, code, at, selection: document.getSelection()! }; +} + +test("scoped shadow selection fallback distinguishes code from prose", () => { + const { root, shadow, prose, code, at, selection } = fixture(); + let node = code; + const getRangeAt = jest.fn(() => at(node)); + const getSelection = jest.fn(() => ({ rangeCount: 1, getRangeAt })); + withProperty(selection, "getComposedRanges", undefined, () => { + withProperty(shadow, "getSelection", getSelection, () => { + expect(resolveCodeContext(root)).toBe("code"); + node = prose; + expect(resolveCodeContext(root)).toBe("prose"); + expect(getSelection).toHaveBeenCalledTimes(2); + expect(getRangeAt).toHaveBeenCalledWith(0); + }); + }); +}); + +test("ordinary shadow range fallback validates the actual range endpoints", () => { + const { root, shadow, prose, code, at, selection } = fixture(); + let node = code; + const getRangeAt = jest.fn(() => at(node)); + withProperty(selection, "getComposedRanges", undefined, () => { + withProperty(shadow, "getSelection", undefined, () => { + withProperty(selection, "rangeCount", 1, () => { + withProperty(selection, "getRangeAt", getRangeAt, () => { + expect(resolveCodeContext(root)).toBe("code"); + node = prose; + expect(resolveCodeContext(root)).toBe("prose"); + node = document.body; + expect(resolveCodeContext(root)).toBe("unknown"); + expect(getRangeAt).toHaveBeenCalledTimes(3); + }); + }); + }); + }); +}); + +test("empty multiple and expanded composed ranges do not become prose", () => { + const { root, prose, code, at, selection } = fixture(); + const ranges: CaretRange[][] = [ + [], + [at(prose), at(code)], + [{ ...at(prose), endOffset: 3 }], + [{ ...at(prose), endContainer: code }], + ]; + for (const result of ranges) { + const getComposedRanges = jest.fn(() => result); + withProperty(selection, "getComposedRanges", getComposedRanges, () => { + expect(resolveCodeContext(root)).toBe("unknown"); + expect(getComposedRanges).toHaveBeenCalledTimes(1); + }); } }); + +test("a throwing composed selection API does not fall back to a different caret", () => { + const { root, selection } = fixture(); + const getComposedRanges = jest.fn(() => { + throw new Error("Composed selection unavailable"); + }); + withProperty(selection, "getComposedRanges", getComposedRanges, () => { + expect(resolveCodeContext(root)).toBe("unknown"); + expect(getComposedRanges).toHaveBeenCalledTimes(1); + }); +}); From 0c1af82078b0ff73c343076a98c6d5b5212bb096 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Fri, 25 Sep 2026 16:27:13 +0200 Subject: [PATCH 08/13] test: register reviewed code-context coverage on the draft branch --- .github/workflows/code-context-audit.yml | 86 ++++++++++++++++++------ 1 file changed, 65 insertions(+), 21 deletions(-) diff --git a/.github/workflows/code-context-audit.yml b/.github/workflows/code-context-audit.yml index 8cde6ce22..594f221cc 100644 --- a/.github/workflows/code-context-audit.yml +++ b/.github/workflows/code-context-audit.yml @@ -9,36 +9,80 @@ permissions: jobs: audit: - if: github.event.pull_request.number == 412 + if: >- + github.event.pull_request.number == 412 && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref == 'feat/automatic-rich-text-code-mode' + permissions: + contents: write runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 10 steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} - uses: oven-sh/setup-bun@v2 with: bun-version-file: package.json - run: bun install --frozen-lockfile - - name: Focused and full-suite comparison + - name: Register coverage without rewriting existing behaviors run: | python3 - <<'PY' - import subprocess - import sys + import json + from pathlib import Path - commands = [ - ["bun", "test", "tests/CodeContextShadow.test.ts", "tests/CodeContextResolver.test.ts", "tests/CodeContextGrammar.test.ts"], - ["bun", "run", "test"], + matrix_path = Path('tests/e2e/coverage-matrix.json') + baseline_path = Path('tests/e2e/coverage-baseline-ids.json') + matrix = json.loads(matrix_path.read_text()) + baseline = json.loads(baseline_path.read_text()) + additions = [ + { + 'id': 'grammar_rich_text_code_protection', + 'description': 'Caret-local rich-text code protection suppresses prose grammar and restores configured rules in prose without changing settings; code-safe opt-in rules remain available.', + 'coverage': [ + {'layer': 'unit', 'file': 'tests/CodeContextGrammar.test.ts', 'test': 'default grammar protects Quill code and resumes in prose without reconfiguration'}, + {'layer': 'unit', 'file': 'tests/CodeContextGrammar.test.ts', 'test': 'all automatic grammar triggers receive code protection from the shared resolver'}, + {'layer': 'unit', 'file': 'tests/CodeContextGrammar.test.ts', 'test': 'Enter virtual word-boundary grammar respects the current code region'}, + {'layer': 'unit', 'file': 'tests/CodeContextGrammar.test.ts', 'test': 'automatic protection preserves the existing optional code-safe bracket rule'}, + ], + }, + { + 'id': 'grammar_code_context_selection_safety', + 'description': 'The code detector validates owning-document and composed or scoped shadow selections; missing, foreign, expanded, ambiguous, and failed selections do not become prose.', + 'coverage': [ + {'layer': 'unit', 'file': 'tests/CodeContextResolver.test.ts', 'test': 'supplies nested shadow roots to composed selection resolution'}, + {'layer': 'unit', 'file': 'tests/CodeContextResolver.test.ts', 'test': "uses the editor's owning document for iframe selections"}, + {'layer': 'unit', 'file': 'tests/CodeContextResolver.test.ts', 'test': 'selection API failure cannot classify unknown context as prose'}, + {'layer': 'unit', 'file': 'tests/CodeContextShadow.test.ts', 'test': 'scoped shadow selection fallback distinguishes code from prose'}, + {'layer': 'unit', 'file': 'tests/CodeContextShadow.test.ts', 'test': 'ordinary shadow range fallback validates the actual range endpoints'}, + {'layer': 'unit', 'file': 'tests/CodeContextShadow.test.ts', 'test': 'empty multiple and expanded composed ranges do not become prose'}, + {'layer': 'unit', 'file': 'tests/CodeContextShadow.test.ts', 'test': 'a throwing composed selection API does not fall back to a different caret'}, + ], + }, + { + 'id': 'grammar_code_context_formatting_changes', + 'description': 'Formatting-only changes are rechecked without caching; unrelated code siblings and typography heuristics do not disable prose.', + 'coverage': [ + {'layer': 'unit', 'file': 'tests/CodeContextResolver.test.ts', 'test': 'rechecks formatting-only DOM changes without cached text or input events'}, + {'layer': 'unit', 'file': 'tests/CodeContextResolver.test.ts', 'test': 'does not disable prose text next to inline code'}, + {'layer': 'unit', 'file': 'tests/CodeContextResolver.test.ts', 'test': 'does not guess code from typography, spellcheck, generic classes, or text'}, + ], + }, ] - failed = False - for command in commands: - result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - lines = result.stdout.splitlines() - print("COMMAND:", " ".join(command), "EXIT:", result.returncode) - indices = set(range(max(0, len(lines) - 25), len(lines))) - for index, line in enumerate(lines): - if "(fail)" in line or "error:" in line or "Expected:" in line: - indices.update(range(max(0, index - 18), min(len(lines), index + 12))) - for index in sorted(indices): - print(lines[index]) - failed = failed or result.returncode != 0 - sys.exit(1 if failed else 0) + existing = {item['id'] for item in matrix['behaviors']} + for addition in additions: + if addition['id'] not in existing: + matrix['behaviors'].append(addition) + if addition['id'] not in baseline['baselineBehaviorIds']: + baseline['baselineBehaviorIds'].append(addition['id']) + for destination, content in [(matrix_path, matrix), (baseline_path, baseline)]: + destination.write_text(json.dumps(content, indent=2, ensure_ascii=False) + '\n') PY + bunx prettier --write tests/e2e/coverage-matrix.json tests/e2e/coverage-baseline-ids.json + bun run check:e2e:coverage + if git diff --quiet; then exit 0; fi + git add -- tests/e2e/coverage-matrix.json tests/e2e/coverage-baseline-ids.json + git diff --cached --check + git diff --cached --stat + git -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' commit -m 'test: register automatic code-context regressions' + git push origin HEAD:refs/heads/feat/automatic-rich-text-code-mode From c746814c75cef0b8b84dbd5a1c4400b3423933d3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:27:43 +0000 Subject: [PATCH 09/13] test: register automatic code-context regressions --- tests/e2e/coverage-baseline-ids.json | 5 +- tests/e2e/coverage-matrix.json | 88 ++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/tests/e2e/coverage-baseline-ids.json b/tests/e2e/coverage-baseline-ids.json index d5d60682b..854ac7dcc 100644 --- a/tests/e2e/coverage-baseline-ids.json +++ b/tests/e2e/coverage-baseline-ids.json @@ -151,6 +151,9 @@ "script_switch_handover", "arabic_tatweel_stripping", "arabic_punctuation_handling", - "arabic_measurement_formatting" + "arabic_measurement_formatting", + "grammar_rich_text_code_protection", + "grammar_code_context_selection_safety", + "grammar_code_context_formatting_changes" ] } diff --git a/tests/e2e/coverage-matrix.json b/tests/e2e/coverage-matrix.json index 28b42046c..4a0be5c35 100644 --- a/tests/e2e/coverage-matrix.json +++ b/tests/e2e/coverage-matrix.json @@ -2366,6 +2366,94 @@ "test": "Arabic-Indic digits stay unrecognised outside ar_SA" } ] + }, + { + "id": "grammar_rich_text_code_protection", + "description": "Caret-local rich-text code protection suppresses prose grammar and restores configured rules in prose without changing settings; code-safe opt-in rules remain available.", + "coverage": [ + { + "layer": "unit", + "file": "tests/CodeContextGrammar.test.ts", + "test": "default grammar protects Quill code and resumes in prose without reconfiguration" + }, + { + "layer": "unit", + "file": "tests/CodeContextGrammar.test.ts", + "test": "all automatic grammar triggers receive code protection from the shared resolver" + }, + { + "layer": "unit", + "file": "tests/CodeContextGrammar.test.ts", + "test": "Enter virtual word-boundary grammar respects the current code region" + }, + { + "layer": "unit", + "file": "tests/CodeContextGrammar.test.ts", + "test": "automatic protection preserves the existing optional code-safe bracket rule" + } + ] + }, + { + "id": "grammar_code_context_selection_safety", + "description": "The code detector validates owning-document and composed or scoped shadow selections; missing, foreign, expanded, ambiguous, and failed selections do not become prose.", + "coverage": [ + { + "layer": "unit", + "file": "tests/CodeContextResolver.test.ts", + "test": "supplies nested shadow roots to composed selection resolution" + }, + { + "layer": "unit", + "file": "tests/CodeContextResolver.test.ts", + "test": "uses the editor's owning document for iframe selections" + }, + { + "layer": "unit", + "file": "tests/CodeContextResolver.test.ts", + "test": "selection API failure cannot classify unknown context as prose" + }, + { + "layer": "unit", + "file": "tests/CodeContextShadow.test.ts", + "test": "scoped shadow selection fallback distinguishes code from prose" + }, + { + "layer": "unit", + "file": "tests/CodeContextShadow.test.ts", + "test": "ordinary shadow range fallback validates the actual range endpoints" + }, + { + "layer": "unit", + "file": "tests/CodeContextShadow.test.ts", + "test": "empty multiple and expanded composed ranges do not become prose" + }, + { + "layer": "unit", + "file": "tests/CodeContextShadow.test.ts", + "test": "a throwing composed selection API does not fall back to a different caret" + } + ] + }, + { + "id": "grammar_code_context_formatting_changes", + "description": "Formatting-only changes are rechecked without caching; unrelated code siblings and typography heuristics do not disable prose.", + "coverage": [ + { + "layer": "unit", + "file": "tests/CodeContextResolver.test.ts", + "test": "rechecks formatting-only DOM changes without cached text or input events" + }, + { + "layer": "unit", + "file": "tests/CodeContextResolver.test.ts", + "test": "does not disable prose text next to inline code" + }, + { + "layer": "unit", + "file": "tests/CodeContextResolver.test.ts", + "test": "does not guess code from typography, spellcheck, generic classes, or text" + } + ] } ] } From 0c296b6ec9c3037993ffb31f70116b3bafd145e0 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Fri, 25 Sep 2026 16:30:03 +0200 Subject: [PATCH 10/13] test: close vacuous coverage gaps and remove temporary audit workflow Verify that each grammar trigger receives the actual prose/protected hint through the real engine. Prove that composed-selection failures cannot silently fall back to a different valid prose caret. Document scoped fixtures and registered coverage, and restore the unchanged normal CI workflow set. --- .github/workflows/code-context-audit.yml | 88 ------------------------ docs/automatic-code-context.md | 46 +++++++++---- tests/CodeContextGrammar.test.ts | 38 ++++++---- tests/CodeContextShadow.test.ts | 16 +++-- 4 files changed, 69 insertions(+), 119 deletions(-) delete mode 100644 .github/workflows/code-context-audit.yml diff --git a/.github/workflows/code-context-audit.yml b/.github/workflows/code-context-audit.yml deleted file mode 100644 index 594f221cc..000000000 --- a/.github/workflows/code-context-audit.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Code context audit - -on: - pull_request: - branches: [master] - -permissions: - contents: read - -jobs: - audit: - if: >- - github.event.pull_request.number == 412 && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.head.ref == 'feat/automatic-rich-text-code-mode' - permissions: - contents: write - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - - uses: oven-sh/setup-bun@v2 - with: - bun-version-file: package.json - - run: bun install --frozen-lockfile - - name: Register coverage without rewriting existing behaviors - run: | - python3 - <<'PY' - import json - from pathlib import Path - - matrix_path = Path('tests/e2e/coverage-matrix.json') - baseline_path = Path('tests/e2e/coverage-baseline-ids.json') - matrix = json.loads(matrix_path.read_text()) - baseline = json.loads(baseline_path.read_text()) - additions = [ - { - 'id': 'grammar_rich_text_code_protection', - 'description': 'Caret-local rich-text code protection suppresses prose grammar and restores configured rules in prose without changing settings; code-safe opt-in rules remain available.', - 'coverage': [ - {'layer': 'unit', 'file': 'tests/CodeContextGrammar.test.ts', 'test': 'default grammar protects Quill code and resumes in prose without reconfiguration'}, - {'layer': 'unit', 'file': 'tests/CodeContextGrammar.test.ts', 'test': 'all automatic grammar triggers receive code protection from the shared resolver'}, - {'layer': 'unit', 'file': 'tests/CodeContextGrammar.test.ts', 'test': 'Enter virtual word-boundary grammar respects the current code region'}, - {'layer': 'unit', 'file': 'tests/CodeContextGrammar.test.ts', 'test': 'automatic protection preserves the existing optional code-safe bracket rule'}, - ], - }, - { - 'id': 'grammar_code_context_selection_safety', - 'description': 'The code detector validates owning-document and composed or scoped shadow selections; missing, foreign, expanded, ambiguous, and failed selections do not become prose.', - 'coverage': [ - {'layer': 'unit', 'file': 'tests/CodeContextResolver.test.ts', 'test': 'supplies nested shadow roots to composed selection resolution'}, - {'layer': 'unit', 'file': 'tests/CodeContextResolver.test.ts', 'test': "uses the editor's owning document for iframe selections"}, - {'layer': 'unit', 'file': 'tests/CodeContextResolver.test.ts', 'test': 'selection API failure cannot classify unknown context as prose'}, - {'layer': 'unit', 'file': 'tests/CodeContextShadow.test.ts', 'test': 'scoped shadow selection fallback distinguishes code from prose'}, - {'layer': 'unit', 'file': 'tests/CodeContextShadow.test.ts', 'test': 'ordinary shadow range fallback validates the actual range endpoints'}, - {'layer': 'unit', 'file': 'tests/CodeContextShadow.test.ts', 'test': 'empty multiple and expanded composed ranges do not become prose'}, - {'layer': 'unit', 'file': 'tests/CodeContextShadow.test.ts', 'test': 'a throwing composed selection API does not fall back to a different caret'}, - ], - }, - { - 'id': 'grammar_code_context_formatting_changes', - 'description': 'Formatting-only changes are rechecked without caching; unrelated code siblings and typography heuristics do not disable prose.', - 'coverage': [ - {'layer': 'unit', 'file': 'tests/CodeContextResolver.test.ts', 'test': 'rechecks formatting-only DOM changes without cached text or input events'}, - {'layer': 'unit', 'file': 'tests/CodeContextResolver.test.ts', 'test': 'does not disable prose text next to inline code'}, - {'layer': 'unit', 'file': 'tests/CodeContextResolver.test.ts', 'test': 'does not guess code from typography, spellcheck, generic classes, or text'}, - ], - }, - ] - existing = {item['id'] for item in matrix['behaviors']} - for addition in additions: - if addition['id'] not in existing: - matrix['behaviors'].append(addition) - if addition['id'] not in baseline['baselineBehaviorIds']: - baseline['baselineBehaviorIds'].append(addition['id']) - for destination, content in [(matrix_path, matrix), (baseline_path, baseline)]: - destination.write_text(json.dumps(content, indent=2, ensure_ascii=False) + '\n') - PY - bunx prettier --write tests/e2e/coverage-matrix.json tests/e2e/coverage-baseline-ids.json - bun run check:e2e:coverage - if git diff --quiet; then exit 0; fi - git add -- tests/e2e/coverage-matrix.json tests/e2e/coverage-baseline-ids.json - git diff --cached --check - git diff --cached --stat - git -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' commit -m 'test: register automatic code-context regressions' - git push origin HEAD:refs/heads/feat/automatic-rich-text-code-mode diff --git a/docs/automatic-code-context.md b/docs/automatic-code-context.md index 6e54035df..e0e2a8cd8 100644 --- a/docs/automatic-code-context.md +++ b/docs/automatic-code-context.md @@ -60,23 +60,41 @@ Google Docs canvas code formatting also need dedicated adapters. ## Tests -`tests/CodeContextResolver.test.ts` covers the supplied mixed Quill markup, empty -blocks, semantic/inline code, highlighted descendants, prose restoration, -formatting-only changes, negative heuristics, existing protected controls, -ambiguous boundaries, owning-document selection, composed shadow ranges, and -selection API failures. - -`tests/CodeContextGrammar.test.ts` exercises the real grammar coordinator and -catalog: default-rule protection and restoration, all automatic trigger types, -Enter boundaries, and preservation of the optional code-safe bracket rule. +`tests/CodeContextResolver.test.ts` covers mixed Quill markup, empty blocks, +semantic/inline code, highlighted descendants, prose restoration, formatting-only +changes, negative heuristics, protected controls, ambiguous boundaries, iframe +selections, nested composed shadow ranges, and selection API failures. + +`tests/CodeContextGrammar.test.ts` uses the real coordinator and rule catalog to +check default-rule protection/restoration, Enter boundaries, and preservation of +the optional code-safe bracket rule. A call-through engine spy verifies the +context delivered for every trigger; a null result from an empty idle/paste +pipeline alone would not establish that protection was propagated. + +`tests/CodeContextShadow.test.ts` checks composed-range rejection and both scoped +and ordinary range fallbacks. Positive prose/code controls and explicit call +assertions prevent a generic unknown result from making a negative test pass +without exercising its intended branch. A throwing composed API must not fall +back to a different, otherwise-valid prose caret. + +DOM API fixtures use scoped own-property overrides with exact descriptor +restoration in `finally`. The original instance spies on jsdom's inherited +`Document.getSelection` did not affect the actual reads in Bun CI. Supplementing +the real Selection object also avoids replacing it with an incomplete mock. + +The coverage registry tracks these behaviors under +`grammar_rich_text_code_protection`, `grammar_code_context_selection_safety`, and +`grammar_code_context_formatting_changes`. Unit-level coverage provides precise +control of DOM API capabilities and failures; it does not claim live Slack or +Google Docs validation. Run the focused repository tests with: ```sh -bun test tests/CodeContextResolver.test.ts tests/CodeContextGrammar.test.ts +bun test tests/CodeContextResolver.test.ts tests/CodeContextGrammar.test.ts tests/CodeContextShadow.test.ts ``` -Before marking the draft ready, run the repository checks, unit suite, coverage -registry validation, and smoke/full extension suites on Chrome and Firefox as -specified in `docs/agents/testing.md`. Focused DOM checks are not a substitute -for the complete extension or live Slack/editor validation. +Run the full repository checks, unit suite, coverage registry validation, and +smoke/full extension suites on Chrome and Firefox as specified in +`docs/agents/testing.md`. Browser regression suites and focused DOM tests serve +different purposes; neither is a claim of manual validation in live Slack. diff --git a/tests/CodeContextGrammar.test.ts b/tests/CodeContextGrammar.test.ts index 7ee6baf77..4f30d48f7 100644 --- a/tests/CodeContextGrammar.test.ts +++ b/tests/CodeContextGrammar.test.ts @@ -1,4 +1,5 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, jest, test } from "bun:test"; +import { GrammarRuleEngine } from "../src/core/domain/grammar/GrammarRuleEngine"; import { GRAMMAR_RULE_CATALOG } from "../src/core/domain/grammar/ruleCatalog"; import { measurementEditingContext } from "../src/adapters/chrome/content-script/suggestions/MeasurementEditingContext"; import { SuggestionGrammarCoordinator } from "../src/adapters/chrome/content-script/suggestions/SuggestionGrammarCoordinator"; @@ -60,19 +61,30 @@ test("default grammar protects Quill code and resumes in prose without reconfigu }); test("all automatic grammar triggers receive code protection from the shared resolver", () => { - const { root, code } = fixture(); + const { root, prose, code } = fixture(); const grammar = coordinator(); - select(code); - for (const trigger of ["insertChar", "wordBoundary", "idle", "paste"] as const) { - expect( - grammar.run({ - beforeCursor: "teh ", - afterCursor: "", - inputAction: "insert", - triggers: [trigger], - measurementContext: measurementEditingContext(root), - }), - ).toBeNull(); + // Call through to the real engine. A null result alone proves nothing for a + // trigger whose current pipeline is empty (for example idle or paste). + const process = jest.spyOn(GrammarRuleEngine.prototype, "processSequence"); + try { + for (const trigger of ["insertChar", "wordBoundary", "idle", "paste"] as const) { + for (const [node, expectedContext] of [[prose, "prose"], [code, "protected"]] as const) { + select(node); + const edit = grammar.run({ + beforeCursor: "teh ", + afterCursor: "", + inputAction: "insert", + triggers: [trigger], + measurementContext: measurementEditingContext(root), + }); + expect(process.mock.calls.at(-1)?.[0]).toEqual([trigger]); + expect(process.mock.calls.at(-1)?.[1].hints?.measurementContext).toBe(expectedContext); + if (expectedContext === "protected") expect(edit).toBeNull(); + } + } + expect(process).toHaveBeenCalledTimes(8); + } finally { + process.mockRestore(); } }); diff --git a/tests/CodeContextShadow.test.ts b/tests/CodeContextShadow.test.ts index 88ca8b56d..af18c825c 100644 --- a/tests/CodeContextShadow.test.ts +++ b/tests/CodeContextShadow.test.ts @@ -89,12 +89,20 @@ test("empty multiple and expanded composed ranges do not become prose", () => { }); test("a throwing composed selection API does not fall back to a different caret", () => { - const { root, selection } = fixture(); + const { root, shadow, prose, at, selection } = fixture(); + const fallback = jest.fn(() => ({ rangeCount: 1, getRangeAt: () => at(prose) })); const getComposedRanges = jest.fn(() => { throw new Error("Composed selection unavailable"); }); - withProperty(selection, "getComposedRanges", getComposedRanges, () => { - expect(resolveCodeContext(root)).toBe("unknown"); - expect(getComposedRanges).toHaveBeenCalledTimes(1); + withProperty(shadow, "getSelection", fallback, () => { + withProperty(selection, "getComposedRanges", undefined, () => { + expect(resolveCodeContext(root)).toBe("prose"); + }); + fallback.mockClear(); + withProperty(selection, "getComposedRanges", getComposedRanges, () => { + expect(resolveCodeContext(root)).toBe("unknown"); + expect(getComposedRanges).toHaveBeenCalledTimes(1); + expect(fallback).not.toHaveBeenCalled(); + }); }); }); From 8e7615cafa371f885cf5ab739736cca415124d76 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Fri, 25 Sep 2026 16:33:10 +0200 Subject: [PATCH 11/13] style: simplify trigger-test iteration and keep assertions readable --- tests/CodeContextGrammar.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/CodeContextGrammar.test.ts b/tests/CodeContextGrammar.test.ts index 4f30d48f7..c1493bd0d 100644 --- a/tests/CodeContextGrammar.test.ts +++ b/tests/CodeContextGrammar.test.ts @@ -68,7 +68,8 @@ test("all automatic grammar triggers receive code protection from the shared res const process = jest.spyOn(GrammarRuleEngine.prototype, "processSequence"); try { for (const trigger of ["insertChar", "wordBoundary", "idle", "paste"] as const) { - for (const [node, expectedContext] of [[prose, "prose"], [code, "protected"]] as const) { + for (const node of [prose, code]) { + const expectedContext = node === prose ? "prose" : "protected"; select(node); const edit = grammar.run({ beforeCursor: "teh ", @@ -77,8 +78,9 @@ test("all automatic grammar triggers receive code protection from the shared res triggers: [trigger], measurementContext: measurementEditingContext(root), }); - expect(process.mock.calls.at(-1)?.[0]).toEqual([trigger]); - expect(process.mock.calls.at(-1)?.[1].hints?.measurementContext).toBe(expectedContext); + const lastCall = process.mock.calls.at(-1); + expect(lastCall?.[0]).toEqual([trigger]); + expect(lastCall?.[1].hints?.measurementContext).toBe(expectedContext); if (expectedContext === "protected") expect(edit).toBeNull(); } } From 27c56f001317c9dc3c9235876e74a2bc2f39a63f Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Fri, 25 Sep 2026 17:30:22 +0200 Subject: [PATCH 12/13] fix: keep code suggestions free of automatic sentence capitalization Carry caret-local suppression through the existing prediction request and per-run override without changing saved settings or shared predictor state. Preserve authored capitals, raw candidate casing, snippets and prose behavior. Add a regression proven to fail on the prior code, request-isolation tests, Tab-acceptance coverage and a real Quill code-to-prose browser regression. --- docs/automatic-code-context.md | 13 +- .../background/BackgroundServiceWorker.ts | 3 +- .../background/PredictionInputProcessor.ts | 3 +- .../chrome/background/PredictionManager.ts | 4 +- .../background/PredictionOrchestrator.ts | 1 + .../chrome/background/PredictionTypes.d.ts | 7 +- .../chrome/background/PresageHandler.ts | 2 + .../chrome/background/router/MessageRouter.ts | 12 +- .../content-script/ContentMessageHandler.ts | 1 + .../SuggestionPredictionCoordinator.ts | 4 + src/core/domain/messageTypes.d.ts | 2 + tests/CodePredictionCapitalization.test.ts | 209 ++++++++++++++++++ tests/SuggestionManager.test.ts | 48 ++++ tests/e2e/coverage-baseline-ids.json | 3 +- tests/e2e/coverage-matrix.json | 26 +++ tests/e2e/full.e2e.test.ts | 97 ++++++++ 16 files changed, 425 insertions(+), 10 deletions(-) create mode 100644 tests/CodePredictionCapitalization.test.ts diff --git a/docs/automatic-code-context.md b/docs/automatic-code-context.md index e0e2a8cd8..831f5f902 100644 --- a/docs/automatic-code-context.md +++ b/docs/automatic-code-context.md @@ -41,15 +41,16 @@ local grammar paths, including Enter's virtual word-boundary processing. ## Semantics and scope -This is automatic **grammar protection**, not a new autocomplete mode. The +This applies automatic **grammar and prediction-casing protection**, not a new autocomplete mode. The existing `codeSafe` rule allowlist still applies; an explicitly enabled `autoBracketClose` remains enabled. Unknown/protected does not introduce a new "block every extension action" policy. Existing composition, edit eligibility, and selection-stability guards remain responsible for their respective checks. No settings, migrations, permissions, network requests, logging of typed text, -prediction messages, or keyboard interception are added. Suggestions, explicit -snippet acceptance, the early-Tab bridge, and Markdown parsing are unchanged. +or keyboard interception are added. Prediction requests carry an optional +sentence-casing suppression flag for code/literal contexts; explicit snippet +acceptance, the early-Tab bridge, and Markdown parsing are otherwise unchanged. This change does not add final replacement-range validation across inline-code boundaries, clip grammar context to prose-only spans, or add stale-prediction @@ -98,3 +99,9 @@ Run the full repository checks, unit suite, coverage registry validation, and smoke/full extension suites on Chrome and Firefox as specified in `docs/agents/testing.md`. Browser regression suites and focused DOM tests serve different purposes; neither is a claim of manual validation in live Slack. + +## Prediction sentence casing + +The current editing context also suppresses automatic sentence capitalization in prediction results. The content script sends a per-request boolean through the existing runtime message and prediction configuration override; it does not change saved settings or the shared predictor configuration. Thus `what . wa` can offer and insert `was` in code while prose still offers `Was`. Explicitly typed capitals and original candidate/snippet casing are retained; results are not blindly lowercased. Google Docs virtual prediction sessions without an element retain their existing behavior. + +The regression covers request-local and overlapping code/prose predictions, mid-word suffixes, preserved candidate/snippet casing, content-message forwarding, and actual Tab acceptance. The full Chrome/Firefox suite additionally tests the built extension in a real Quill code block and then in prose in the same composer. This is an automated Quill fixture, not a claim of live Slack validation. diff --git a/src/adapters/chrome/background/BackgroundServiceWorker.ts b/src/adapters/chrome/background/BackgroundServiceWorker.ts index 32d4da76a..57eb4b7da 100644 --- a/src/adapters/chrome/background/BackgroundServiceWorker.ts +++ b/src/adapters/chrome/background/BackgroundServiceWorker.ts @@ -5,6 +5,7 @@ import { SettingsManager } from "@core/application/settingsManager"; import { CoreSettingsRepository } from "@core/application/repositories/CoreSettingsRepository"; import { LanguageDetector, type AutoLanguageSessionLookup } from "./LanguageDetector"; import { PredictionManager } from "./PredictionManager"; +import type { PredictionConfigOverride } from "./PredictionTypes"; import { TabMessenger } from "./TabMessenger"; import { ProductivityStatsManager } from "./ProductivityStatsManager"; import { migrateSettingsV3 } from "@core/application/settings/SettingsMigrationV3"; @@ -88,7 +89,7 @@ export class BackgroundServiceWorker { async runPrediction( message: PredictRequestMessage, - configOverride?: { numSuggestions?: number }, + configOverride?: PredictionConfigOverride, ): Promise { const traceId = this.predictionManager.ensureTraceId(message.context.traceId); const traceMeta = { diff --git a/src/adapters/chrome/background/PredictionInputProcessor.ts b/src/adapters/chrome/background/PredictionInputProcessor.ts index 2c0adb27b..f64a032b8 100644 --- a/src/adapters/chrome/background/PredictionInputProcessor.ts +++ b/src/adapters/chrome/background/PredictionInputProcessor.ts @@ -113,6 +113,7 @@ export class PredictionInputProcessor { numSuggestions: number, predictNextWordAfterSeparatorChar: boolean, afterCursorTokenSuffix?: string, + suppressAutoCapitalize = false, ): { predictionInput: string; lastWord: string; @@ -153,7 +154,7 @@ export class PredictionInputProcessor { wordCount: wordArray.length, newSentence, endsWithSpace, - autoCapitalize: this.autoCapitalize, + autoCapitalize: this.autoCapitalize && suppressAutoCapitalize !== true, }); const doPrediction = this.checkDoPrediction( lastWord, diff --git a/src/adapters/chrome/background/PredictionManager.ts b/src/adapters/chrome/background/PredictionManager.ts index 1ab9390c7..eb21aa2c2 100644 --- a/src/adapters/chrome/background/PredictionManager.ts +++ b/src/adapters/chrome/background/PredictionManager.ts @@ -11,6 +11,7 @@ import type { PredictionDebugEvent, PredictionResult, PredictionRunConfig, + PredictionConfigOverride, PredictorStageDebugInfo, } from "./PredictionTypes"; import libPresageMod from "@third-party/libpresage/libpresage.js"; @@ -111,7 +112,7 @@ export class PredictionManager { text: string, nextChar: string, lang: string, - configOverride?: { numSuggestions?: number }, + configOverride?: PredictionConfigOverride, debugMeta?: PredictionDebugRequestMeta, afterCursorTokenSuffix?: string, ): Promise { @@ -131,6 +132,7 @@ export class PredictionManager { const runConfig: PredictionRunConfig = { numSuggestions: configOverride?.numSuggestions, + ...(configOverride?.suppressAutoCapitalize === true ? { suppressAutoCapitalize: true } : {}), tabId: resolvedDebugMeta.tabId ?? undefined, debugListener: (debugEvent) => { this.recordDebugTrace(debugEvent, resolvedDebugMeta); diff --git a/src/adapters/chrome/background/PredictionOrchestrator.ts b/src/adapters/chrome/background/PredictionOrchestrator.ts index f94362830..a2892285d 100644 --- a/src/adapters/chrome/background/PredictionOrchestrator.ts +++ b/src/adapters/chrome/background/PredictionOrchestrator.ts @@ -117,6 +117,7 @@ export class PredictionOrchestrator { configOverride?.numSuggestions, configOverride?.tabId, afterCursorTokenSuffix, + configOverride?.suppressAutoCapitalize, ); const presageDebug: PredictorStageDebugInfo = { diff --git a/src/adapters/chrome/background/PredictionTypes.d.ts b/src/adapters/chrome/background/PredictionTypes.d.ts index 62ccccac1..37bb6aefd 100644 --- a/src/adapters/chrome/background/PredictionTypes.d.ts +++ b/src/adapters/chrome/background/PredictionTypes.d.ts @@ -38,8 +38,13 @@ export interface PredictionDebugEvent { finalPredictions: string[]; } -export interface PredictionRunConfig { +export interface PredictionConfigOverride { numSuggestions?: number; + /** Disable automatic sentence casing for this request without changing shared config. */ + suppressAutoCapitalize?: boolean; +} + +export interface PredictionRunConfig extends PredictionConfigOverride { tabId?: number; debugListener?: (debugEvent: PredictionDebugEvent) => void; } diff --git a/src/adapters/chrome/background/PresageHandler.ts b/src/adapters/chrome/background/PresageHandler.ts index 74aeceb33..32b939756 100644 --- a/src/adapters/chrome/background/PresageHandler.ts +++ b/src/adapters/chrome/background/PresageHandler.ts @@ -254,6 +254,7 @@ export class PresageHandler { numSuggestionsOverride?: number, tabId?: number, afterCursorTokenSuffix?: string, + suppressAutoCapitalize = false, ): PresagePredictionContext { const effectiveNumSuggestions = typeof numSuggestionsOverride === "number" @@ -266,6 +267,7 @@ export class PresageHandler { effectiveNumSuggestions, this.predictNextWordAfterSeparatorChar, afterCursorTokenSuffix, + suppressAutoCapitalize, ); return { diff --git a/src/adapters/chrome/background/router/MessageRouter.ts b/src/adapters/chrome/background/router/MessageRouter.ts index 1278f955d..ce9f5e1eb 100644 --- a/src/adapters/chrome/background/router/MessageRouter.ts +++ b/src/adapters/chrome/background/router/MessageRouter.ts @@ -311,8 +311,16 @@ export class MessageRouter { () => worker.runPrediction( predictRequestMessage, - domainSettings.hasNumSuggestionsOverride - ? { numSuggestions: domainSettings.numSuggestions } + domainSettings.hasNumSuggestionsOverride || + request.context.suppressAutoCapitalize === true + ? { + ...(domainSettings.hasNumSuggestionsOverride + ? { numSuggestions: domainSettings.numSuggestions } + : {}), + ...(request.context.suppressAutoCapitalize === true + ? { suppressAutoCapitalize: true } + : {}), + } : undefined, ), (cause) => diff --git a/src/adapters/chrome/content-script/ContentMessageHandler.ts b/src/adapters/chrome/content-script/ContentMessageHandler.ts index 8266a5c9d..e70dae808 100644 --- a/src/adapters/chrome/content-script/ContentMessageHandler.ts +++ b/src/adapters/chrome/content-script/ContentMessageHandler.ts @@ -79,6 +79,7 @@ export class ContentMessageHandler { text: context.text, nextChar: context.nextChar, afterCursorTokenSuffix: context.afterCursorTokenSuffix, + ...(context.suppressAutoCapitalize === true ? { suppressAutoCapitalize: true } : {}), inputAction: context.inputAction, suggestionId: context.suggestionId, requestId: context.requestId, diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionPredictionCoordinator.ts b/src/adapters/chrome/content-script/suggestions/SuggestionPredictionCoordinator.ts index 4c34d7e75..b71999915 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionPredictionCoordinator.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionPredictionCoordinator.ts @@ -1,4 +1,5 @@ import { TextTargetAdapter } from "./TextTargetAdapter"; +import { resolveCodeContext } from "./CodeContextResolver"; import type { PredictionRequest, PredictionResponse, SuggestionEntry } from "./types"; import type { PredictionInputAction } from "@core/domain/messageTypes"; import { extractPredictionTokenSuffix } from "@core/domain/predictionToken"; @@ -220,6 +221,9 @@ export class SuggestionPredictionCoordinator { }); this.getPrediction({ + ...(entry.elem && resolveCodeContext(entry.elem) !== "prose" + ? { suppressAutoCapitalize: true } + : {}), text: beforeCursor, nextChar: afterCursor.charAt(0), afterCursorTokenSuffix: extractPredictionTokenSuffix(afterCursor, (char) => diff --git a/src/core/domain/messageTypes.d.ts b/src/core/domain/messageTypes.d.ts index bc87103b3..db50056a1 100644 --- a/src/core/domain/messageTypes.d.ts +++ b/src/core/domain/messageTypes.d.ts @@ -65,6 +65,8 @@ export interface PredictResponseContext { // Context for CMD_CONTENT_SCRIPT_PREDICT_REQ export interface ContentScriptPredictRequestContext { + /** Per-request code/literal context; suppress sentence casing, not authored capitals. */ + suppressAutoCapitalize?: boolean; text: string; nextChar: string; afterCursorTokenSuffix?: string; diff --git a/tests/CodePredictionCapitalization.test.ts b/tests/CodePredictionCapitalization.test.ts new file mode 100644 index 000000000..7b44097e0 --- /dev/null +++ b/tests/CodePredictionCapitalization.test.ts @@ -0,0 +1,209 @@ +import { afterEach, describe, expect, jest, test } from "bun:test"; +import { Capitalization } from "../src/adapters/chrome/background/CapitalizationHelper"; +import { PredictionInputProcessor } from "../src/adapters/chrome/background/PredictionInputProcessor"; +import { PresageHandler } from "../src/adapters/chrome/background/PresageHandler"; +import { PredictionOrchestrator } from "../src/adapters/chrome/background/PredictionOrchestrator"; +import { SuggestionPredictionCoordinator } from "../src/adapters/chrome/content-script/suggestions/SuggestionPredictionCoordinator"; +import { ContentMessageHandler } from "../src/adapters/chrome/content-script/ContentMessageHandler"; +import { mod } from "./fakeLibPresage.js"; + +const originalPredictions = mod.PresageCallback.predictions; +afterEach(() => { + mod.PresageCallback.predictions = originalPredictions; + jest.restoreAllMocks(); +}); + +function backend(autoCapitalize = true) { + const handler = new PresageHandler(mod); + handler.setConfig({ + numSuggestions: 3, + minWordLengthToPredict: 0, + insertSpaceAfterAutocomplete: false, + autoCapitalize, + textExpansions: [], + prefixOnlyMode: false, + userDictionaryList: [], + }); + return { handler, orchestrator: new PredictionOrchestrator(handler) }; +} + +function caret(node: Node): void { + const range = document.createRange(); + range.setStart(node, node.textContent?.length ?? 0); + range.collapse(true); + const selection = document.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); +} + +describe("code prediction capitalization", () => { + test("code suppression is request-local and does not force lowercase", async () => { + mod.PresageCallback.predictions = ["was", "WasmAPI", "camelCase"]; + const { orchestrator } = backend(); + const code = await orchestrator.runPrediction("what . wa", "", "en_US", { + suppressAutoCapitalize: true, + }); + expect(code.predictions).toEqual(["was", "WasmAPI", "camelCase"]); + const prose = await orchestrator.runPrediction("what . wa", "", "en_US"); + expect(prose.predictions[0]).toBe("Was"); + const codeAgain = await orchestrator.runPrediction("what . wa", "", "en_US", { + suppressAutoCapitalize: true, + }); + expect(codeAgain.predictions).toEqual(code.predictions); + }); + + test("overlapping code and prose requests cannot share casing state", async () => { + mod.PresageCallback.predictions = ["was"]; + const { orchestrator } = backend(); + const [code, prose] = await Promise.all([ + orchestrator.runPrediction("what . wa", "", "en_US", { suppressAutoCapitalize: true }), + orchestrator.runPrediction("what . wa", "", "en_US"), + ]); + expect(code.predictions).toEqual(["was"]); + expect(prose.predictions).toEqual(["Was"]); + }); + + for (const [text, expected] of [ + ["wa", Capitalization.None], + ["what . wa", Capitalization.None], + ["what ! wa", Capitalization.None], + ["what ? wa", Capitalization.None], + ["what . ", Capitalization.None], + ["what . Wa", Capitalization.FirstLetter], + ["what . WA", Capitalization.WholeWord], + ] as const) { + test(`code keeps authored casing for ${JSON.stringify(text)}`, () => { + const processor = new PredictionInputProcessor(0, true); + expect(processor.processInput(text, "en_US", 3, true, "", true).doCapitalize).toBe(expected); + }); + } + + test("mid-word completion suppresses sentence casing without dropping the suffix", () => { + const result = new PredictionInputProcessor(1, true).processInput( + "what . wa", + "en_US", + 3, + false, + "s", + true, + ); + expect(result.predictionInput).toBe("was"); + expect(result.doCapitalize).toBe(Capitalization.None); + }); + + test("prose still respects disabled capitalization and strict boolean suppression", () => { + const disabled = new PredictionInputProcessor(1, false); + expect(disabled.processInput("what . wa", "en_US", 3, false).doCapitalize).toBe( + Capitalization.None, + ); + const enabled = new PredictionInputProcessor(1, true); + for (const value of [undefined, false, "true", 1]) { + expect( + enabled.processInput("what . wa", "en_US", 3, false, "", value as boolean | undefined) + .doCapitalize, + ).toBe(Capitalization.FirstLetter); + } + }); + + test("code preserves snippet text and metadata rather than lowercasing the result", () => { + const { handler } = backend(); + const context = handler.preparePredictionContext( + "what . wa", + "", + "en_US", + 3, + undefined, + "", + true, + ); + const result = handler.finalizePrediction( + [{ text: "camelCaseCall()", snippetShortcut: ";wa" }], + context, + ); + expect(result.predictions).toEqual(["camelCaseCall()"]); + expect(result.snippetShortcuts).toEqual([";wa"]); + }); + + test("the live caret supplies suppression for code and restores prose in the same host", () => { + const root = document.createElement("div"); + root.setAttribute("contenteditable", "true"); + Object.defineProperty(root, "isContentEditable", { value: true }); + root.innerHTML = '

what . wa

what . wa
'; + document.body.append(root); + const getPrediction = jest.fn(); + const coordinator = new SuggestionPredictionCoordinator({ + debounceByAction: { insert: 0, delete: 0, other: 0 }, + getPrediction, + lang: "en_US", + minWordLengthToPredict: 1, + separatorRegex: /\s/, + }); + const entry = { + id: 1, + requestId: 0, + latestMentionText: "", + latestMentionStart: 0, + pendingRequestTimer: null, + elem: root, + }; + for (const [block, expected] of [ + [root.firstElementChild!, undefined], + [root.lastElementChild!, true], + [root.firstElementChild!, undefined], + ] as const) { + caret(block.firstChild!); + coordinator.schedule(entry, { + force: true, + clearSuggestions: () => {}, + beforeCursorOverride: "what . wa", + afterCursorOverride: "", + }); + expect(getPrediction.mock.calls.at(-1)?.[0].suppressAutoCapitalize).toBe(expected); + } + root.firstElementChild!.classList.add("ql-code-block"); + coordinator.reconcile(entry, { + clearSuggestions: () => {}, + beforeCursorOverride: "what . wa", + afterCursorOverride: "", + }); + expect(getPrediction.mock.calls.at(-1)?.[0].suppressAutoCapitalize).toBe(true); + }); + + test("content messaging forwards the code flag without persisting it", () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "chrome"); + const sendMessage = jest.fn((message: { context: { suppressAutoCapitalize?: boolean } }) => + Promise.resolve(message), + ); + Object.defineProperty(globalThis, "chrome", { + configurable: true, + value: { runtime: { sendMessage } }, + }); + try { + const handler = new ContentMessageHandler({ + getEnabled: () => true, + setEnabled: () => {}, + toggleEnabled: () => {}, + setConfig: () => {}, + updateLanguage: () => {}, + triggerActiveSuggestion: () => {}, + fulfillPrediction: () => {}, + getLanguage: () => "en_US", + getPredictionGeneration: () => 1, + }); + const request = { + text: "what . wa", + nextChar: "", + suggestionId: 1, + requestId: 1, + lang: "en_US", + }; + handler.handleGetPrediction({ ...request, suppressAutoCapitalize: true }); + expect(sendMessage.mock.calls.at(-1)?.[0].context.suppressAutoCapitalize).toBe(true); + handler.handleGetPrediction(request); + expect(sendMessage.mock.calls.at(-1)?.[0].context.suppressAutoCapitalize).toBeUndefined(); + } finally { + if (descriptor) Object.defineProperty(globalThis, "chrome", descriptor); + else Reflect.deleteProperty(globalThis, "chrome"); + } + }); +}); diff --git a/tests/SuggestionManager.test.ts b/tests/SuggestionManager.test.ts index 1c3c81fd5..0d6a968d2 100644 --- a/tests/SuggestionManager.test.ts +++ b/tests/SuggestionManager.test.ts @@ -378,6 +378,54 @@ describe("SuggestionManager", () => { ).toBeUndefined(); }); + test("Quill code prediction stays lowercase through Tab acceptance", async () => { + const { PresageHandler } = await import("../src/adapters/chrome/background/PresageHandler"); + const { PredictionOrchestrator } = + await import("../src/adapters/chrome/background/PredictionOrchestrator"); + const { mod } = await import("./fakeLibPresage.js"); + const original = mod.PresageCallback.predictions; + try { + mod.PresageCallback.predictions = ["was"]; + const handler = new PresageHandler(mod); + handler.setConfig({ + numSuggestions: 1, + minWordLengthToPredict: 1, + insertSpaceAfterAutocomplete: true, + autoCapitalize: true, + textExpansions: [], + prefixOnlyMode: false, + }); + const backend = new PredictionOrchestrator(handler); + const { manager, getPrediction } = await createManager({ + enabledGrammarRules: ["capitalizeSentenceStart"], + selectByDigit: false, + }); + const root = document.createElement("div"); + root.setAttribute("contenteditable", "true"); + Object.defineProperty(root, "isContentEditable", { value: true }); + root.innerHTML = '
what . wa
'; + document.body.append(root); + manager.queryAndAttachHelper(); + root.focus(); + setContentEditableCursor(root, "what . wa".length); + dispatchInput(root, { inputType: "insertText" }); + const request = await waitForNextCall(getPrediction); + const result = await backend.runPrediction( + request.text, + request.nextChar, + request.lang, + { suppressAutoCapitalize: request.suppressAutoCapitalize }, + request.afterCursorTokenSuffix, + ); + manager.fulfillPrediction(buildResponse(request, result)); + expect(querySuggestionMenuItems()[0]?.textContent?.trim()).toBe("was"); + dispatchKeydown(root, "Tab"); + expect(root.querySelector(".ql-code-block")?.textContent?.trimEnd()).toBe("what . was"); + } finally { + mod.PresageCallback.predictions = original; + } + }); + test("capitalizes an accepted suggestion the way typing the word would", async () => { // Typing "was " capitalized while picking "was" from the menu did not: // acceptance finishes a word without ever reaching the keystroke path. diff --git a/tests/e2e/coverage-baseline-ids.json b/tests/e2e/coverage-baseline-ids.json index 854ac7dcc..0fbbc6d0b 100644 --- a/tests/e2e/coverage-baseline-ids.json +++ b/tests/e2e/coverage-baseline-ids.json @@ -154,6 +154,7 @@ "arabic_measurement_formatting", "grammar_rich_text_code_protection", "grammar_code_context_selection_safety", - "grammar_code_context_formatting_changes" + "grammar_code_context_formatting_changes", + "grammar_code_prediction_casing" ] } diff --git a/tests/e2e/coverage-matrix.json b/tests/e2e/coverage-matrix.json index 4a0be5c35..d586e145c 100644 --- a/tests/e2e/coverage-matrix.json +++ b/tests/e2e/coverage-matrix.json @@ -2454,6 +2454,32 @@ "test": "does not guess code from typography, spellcheck, generic classes, or text" } ] + }, + { + "id": "grammar_code_prediction_casing", + "description": "Code context suppresses automatic sentence casing before suggestions are rendered and accepted, preserving authored capitals and restoring prose behavior per request.", + "coverage": [ + { + "layer": "unit", + "file": "tests/CodePredictionCapitalization.test.ts", + "test": "code suppression is request-local and does not force lowercase" + }, + { + "layer": "unit", + "file": "tests/CodePredictionCapitalization.test.ts", + "test": "overlapping code and prose requests cannot share casing state" + }, + { + "layer": "unit", + "file": "tests/SuggestionManager.test.ts", + "test": "Quill code prediction stays lowercase through Tab acceptance" + }, + { + "layer": "e2e-full", + "file": "tests/e2e/full.e2e.test.ts", + "test": "Quill code predictions keep lowercase through Tab and restore prose casing" + } + ] } ] } diff --git a/tests/e2e/full.e2e.test.ts b/tests/e2e/full.e2e.test.ts index b9ad63df8..133fff9be 100644 --- a/tests/e2e/full.e2e.test.ts +++ b/tests/e2e/full.e2e.test.ts @@ -2950,6 +2950,103 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { browserTimeout(30000, 50000), ); + test( + "Quill code predictions keep lowercase through Tab and restore prose casing", + async () => { + try { + await setGrammarRulesAndWait(worker!, ["capitalizeSentenceStart"]); + await setSettingAndWait(worker!, KEY_LANGUAGE, "en_US"); + await setSettingAndWait(worker!, KEY_ENABLED_LANGUAGES, SUPPORTED_PREDICTION_LANGUAGE_KEYS); + await setSettingAndWait(worker!, KEY_MIN_WORD_LENGTH_TO_PREDICT, 1); + await setSettingAndWait(worker!, KEY_INLINE_SUGGESTION, false); + await setSettingAndWait(worker!, KEY_AUTOCOMPLETE_ON_TAB, true); + await applyConfigChange(browser, worker!); + await gotoTestPage(page, { enableQuill: true }); + await page.bringToFront(); + await waitForInputReady(page, QUILL_SELECTOR); + await page.focus(QUILL_SELECTOR); + await page.evaluate(() => { + const quill = ( + window as typeof window & { + __testQuill?: { + setText: (text: string, source?: string) => void; + formatLine: ( + index: number, + length: number, + name: string, + value: boolean, + source?: string, + ) => void; + setSelection: (index: number, length: number, source?: string) => void; + }; + } + ).__testQuill; + if (!quill) throw new Error("Quill test instance not found"); + quill.setText("what . \nwhat . \n", "silent"); + quill.formatLine(0, 1, "code-block", true, "api"); + quill.setSelection("what . ".length, 0, "api"); + if (!document.querySelector(".ql-editor .ql-code-block")) + throw new Error("Missing actual Quill code block"); + }); + for (const expected of ["was", "Was"]) { + if (expected === "Was") { + await page.keyboard.press("Escape"); + await page.evaluate(() => { + const quill = ( + window as typeof window & { + __testQuill?: { + getText: () => string; + setSelection: (index: number, length: number, source?: string) => void; + }; + } + ).__testQuill; + if (!quill) throw new Error("Quill test instance not found"); + quill.setSelection(quill.getText().indexOf("\n") + 1 + "what . ".length, 0, "api"); + }); + } + await page.keyboard.type("wa"); + const index = await waitUntil( + `Quill offers ${expected} with context-correct casing`, + async () => { + const suggestions = await waitForVisibleSuggestionTexts(page); + const found = suggestions.findIndex((text) => text.trim() === expected); + return found >= 0 ? { value: found } : false; + }, + { timeoutMs: browserTimeout(5000, 10000), intervalMs: 50 }, + ); + for (let i = 0; i < index.value; i += 1) await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Tab"); + await waitUntil( + `Quill inserts ${expected} without changing code casing`, + async () => + page.evaluate((inProse) => { + const quill = ( + window as typeof window & { __testQuill?: { getText: () => string } } + ).__testQuill; + const lines = quill + ?.getText() + .replace(/\u00a0/g, " ") + .split("\n"); + const code = document + .querySelector(".ql-editor .ql-code-block") + ?.textContent?.trimEnd(); + return ( + code === "what . was" && + lines?.[0]?.trimEnd() === "what . was" && + lines?.[1]?.trimEnd() === (inProse ? "what . Was" : "what .") + ); + }, expected === "Was"), + { timeoutMs: browserTimeout(5000, 10000), intervalMs: 50 }, + ); + } + } finally { + await setGrammarRulesAndWait(worker!, []); + await applyConfigChange(browser, worker!); + } + }, + browserTimeout(45000, 70000), + ); + test( "Quill preserves block structure and caret-correct insertion on Tab acceptance", async () => { From bcbd78f5157cdc4ea2bba3f39c528bd20ca237e8 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Fri, 25 Sep 2026 18:07:30 +0200 Subject: [PATCH 13/13] refactor: simplify code-context plumbing and regression fixtures Replace nested prediction override construction with explicit independent branches, retain native DOM selection types, and remove duplicate readonly classification. Share test-only editor/caret/property fixtures with verified exception-safe cleanup, reuse Quill types, and eliminate nested E2E polling. Preserve all existing behavior and add combined casing/site-override coverage. The full unit runner passes before and after the source refactor; static and coverage checks pass on this tree. No workflow changes or weakened test gates. --- docs/automatic-code-context.md | 152 +++++++----------- .../chrome/background/router/MessageRouter.ts | 25 ++- .../suggestions/CodeContextResolver.ts | 5 +- .../suggestions/MeasurementEditingContext.ts | 1 - tests/CodeContextGrammar.test.ts | 15 +- tests/CodeContextResolver.test.ts | 36 +---- tests/CodeContextShadow.test.ts | 50 ++++-- tests/CodePredictionCapitalization.test.ts | 31 +--- tests/background.routing.test.ts | 59 +++++++ tests/codeContextTestUtils.ts | 38 +++++ tests/e2e/coverage-matrix.json | 15 ++ tests/e2e/full.e2e.test.ts | 32 +--- 12 files changed, 230 insertions(+), 229 deletions(-) create mode 100644 tests/codeContextTestUtils.ts diff --git a/docs/automatic-code-context.md b/docs/automatic-code-context.md index 831f5f902..de0890e76 100644 --- a/docs/automatic-code-context.md +++ b/docs/automatic-code-context.md @@ -1,107 +1,73 @@ # Automatic rich-text code protection -FluentTyper applies the existing Code-mode grammar filter at the current caret, -without changing saved global/site settings or restarting the content runtime. -Moving back into prose restores the configured prose rules on the next grammar -operation. The resolver is synchronous and uncached, so changing a paragraph's -format without changing its text is also recognized. - -## Supported DOM signals - -- Semantic `code`, `pre`, `kbd`, and `samp` ancestors. Literal/preformatted text is - protected even when it is not a programming language. -- Quill 2's editing DOM: `.ql-code-block` and `.ql-code-block-container`, including - empty blocks and syntax-highlighting descendants. No `.ql-editor` ancestor is - required, so the standalone code-block representation works too. -- Existing whole-editor markers: `.monaco-editor`, `.CodeMirror`, `.cm-editor`, - and `.ace_editor`. - -Code blocks elsewhere in a composer do not change the active prose paragraph's -mode. Inline code does not disable adjacent prose text. Monospace fonts, -`spellcheck="false"`, `data-gramm="false"`, generic `.code`/`language-*` classes, -and text that merely resembles a program are not used as code evidence. - -## Selection and eligibility - -`CodeContextResolver.ts` uses the editor's owning document and inspects the -caret's ancestors. For shadow-root editors it supplies the accessible ancestor -roots to `getComposedRanges()` when available, with scoped-selection/ordinary -range fallbacks. It checks that the returned range actually belongs to the -editor rather than treating a re-scoped host position as prose. - -Missing, foreign, non-collapsed, or unavailable selections are unknown. A -parent/child-offset position immediately adjacent to code also has uncertain -formatting affinity; prose-only rules are withheld rather than choosing a -sibling. An ordinary text-node position in adjacent prose remains eligible. - -`MeasurementEditingContext.ts` retains the existing grammar-hint interface and -sensitive/read-only/input-type exclusions. Unknown and protected contexts map to -its existing `protected` hint. This hint is already consumed by the shared -local grammar paths, including Enter's virtual word-boundary processing. - -## Semantics and scope - -This applies automatic **grammar and prediction-casing protection**, not a new autocomplete mode. The -existing `codeSafe` rule allowlist still applies; an explicitly enabled -`autoBracketClose` remains enabled. Unknown/protected does not introduce a new -"block every extension action" policy. Existing composition, edit eligibility, -and selection-stability guards remain responsible for their respective checks. - -No settings, migrations, permissions, network requests, logging of typed text, -or keyboard interception are added. Prediction requests carry an optional -sentence-casing suppression flag for code/literal contexts; explicit snippet -acceptance, the early-Tab bridge, and Markdown parsing are otherwise unchanged. - -This change does not add final replacement-range validation across inline-code -boundaries, clip grammar context to prose-only spans, or add stale-prediction -region tokens. A caret-local check alone is not a guarantee that every possible -replacement range avoids code. Those broader safeguards need a separate edit- -transaction change and end-to-end validation. Custom model-only code styles and -Google Docs canvas code formatting also need dedicated adapters. +FluentTyper resolves the current caret's code context for each grammar operation +and prediction request. Moving between code and prose, or changing formatting +without changing text, does not change saved settings or restart the runtime. -## Tests +## Detection -`tests/CodeContextResolver.test.ts` covers mixed Quill markup, empty blocks, -semantic/inline code, highlighted descendants, prose restoration, formatting-only -changes, negative heuristics, protected controls, ambiguous boundaries, iframe -selections, nested composed shadow ranges, and selection API failures. +`CodeContextResolver.ts` recognizes semantic `code`, `pre`, `kbd`, and `samp` +ancestors; Quill's `.ql-code-block` and `.ql-code-block-container`; and the existing +Monaco, CodeMirror, and Ace editor markers. Empty blocks and syntax-highlighting +descendants are covered. Preformatted/literal content receives the same protection. +Code elsewhere in the composer does not disable the active prose paragraph. -`tests/CodeContextGrammar.test.ts` uses the real coordinator and rule catalog to -check default-rule protection/restoration, Enter boundaries, and preservation of -the optional code-safe bracket rule. A call-through engine spy verifies the -context delivered for every trigger; a null result from an empty idle/paste -pipeline alone would not establish that protection was propagated. +Monospace fonts, `spellcheck=false`, `data-gramm=false`, generic `.code` or +`language-*` classes, and program-looking text are not standalone code signals. -`tests/CodeContextShadow.test.ts` checks composed-range rejection and both scoped -and ordinary range fallbacks. Positive prose/code controls and explicit call -assertions prevent a generic unknown result from making a negative test pass -without exercising its intended branch. A throwing composed API must not fall -back to a different, otherwise-valid prose caret. +Selection is read from the editor's owning document. Shadow editors use +`getComposedRanges()` when available, with scoped or ordinary range fallbacks. +Every resolved range must be collapsed and belong to the target. Missing, +foreign, expanded, or unavailable selections remain unknown. A parent-offset +caret next to code also remains unknown rather than guessing insertion affinity. +A failed composed-selection call never falls back to a different caret. -DOM API fixtures use scoped own-property overrides with exact descriptor -restoration in `finally`. The original instance spies on jsdom's inherited -`Document.getSelection` did not affect the actual reads in Bun CI. Supplementing -the real Selection object also avoids replacing it with an incomplete mock. +## Grammar and prediction behavior -The coverage registry tracks these behaviors under -`grammar_rich_text_code_protection`, `grammar_code_context_selection_safety`, and -`grammar_code_context_formatting_changes`. Unit-level coverage provides precise -control of DOM API capabilities and failures; it does not claim live Slack or -Google Docs validation. +`MeasurementEditingContext.ts` preserves existing field eligibility exclusions +and maps every non-prose result to the grammar engine's `protected` hint. +Only code-safe grammar rules run there; an explicitly enabled `autoBracketClose` +still runs. This is not a policy that blocks every extension action. -Run the focused repository tests with: +Prediction requests carry optional `suppressAutoCapitalize: true` for non-prose +DOM contexts. The background applies it per request, never to shared predictor +configuration. Thus `what . wa` can offer and insert `was` in code and `Was` in +prose. Authored `Wa`/`WA`, original candidate casing, and snippet text/metadata +retain their existing behavior; results are not blindly lowercased. Virtual +Google Docs prediction sessions without a DOM element retain their prior behavior. -```sh -bun test tests/CodeContextResolver.test.ts tests/CodeContextGrammar.test.ts tests/CodeContextShadow.test.ts -``` +No dependencies, settings migrations, permissions, external requests, typed-text +logging, or keyboard interception are added. Explicit autocomplete and snippet +acceptance remain available. Markdown parsing is unchanged. + +## Limits + +Caret-local detection does not validate every replacement range across inline +code, clip grammar context to prose-only spans, or track stale predictions by +region identity. Those are separate transaction safeguards. Custom model-only +code styles and Google Docs canvas formatting need dedicated adapters. -Run the full repository checks, unit suite, coverage registry validation, and -smoke/full extension suites on Chrome and Firefox as specified in -`docs/agents/testing.md`. Browser regression suites and focused DOM tests serve -different purposes; neither is a claim of manual validation in live Slack. +## Tests + +`CodeContextResolver.test.ts`, `CodeContextGrammar.test.ts`, and +`CodeContextShadow.test.ts` cover detection, selection boundaries/failures, +formatting changes, real grammar hints, and optional code-safe rules. +`codeContextTestUtils.ts` shares editor/caret fixtures and synchronous property +overrides; exact descriptor restoration is tested even for nested exceptions. + +`CodePredictionCapitalization.test.ts` covers casing, request isolation, and +message forwarding. `background.routing.test.ts` covers independent casing and +site suggestion-count overrides. `SuggestionManager.test.ts` checks popup text +and Tab acceptance. The full Chrome/Firefox suite tests the built extension in +real Quill code and prose in the same composer. Automated fixtures are not a +claim of independent live Slack or Google Docs validation. -## Prediction sentence casing +Run the focused tests: -The current editing context also suppresses automatic sentence capitalization in prediction results. The content script sends a per-request boolean through the existing runtime message and prediction configuration override; it does not change saved settings or the shared predictor configuration. Thus `what . wa` can offer and insert `was` in code while prose still offers `Was`. Explicitly typed capitals and original candidate/snippet casing are retained; results are not blindly lowercased. Google Docs virtual prediction sessions without an element retain their existing behavior. +```sh +bun test tests/CodeContextResolver.test.ts tests/CodeContextGrammar.test.ts tests/CodeContextShadow.test.ts tests/CodePredictionCapitalization.test.ts +``` -The regression covers request-local and overlapping code/prose predictions, mid-word suffixes, preserved candidate/snippet casing, content-message forwarding, and actual Tab acceptance. The full Chrome/Firefox suite additionally tests the built extension in a real Quill code block and then in prose in the same composer. This is an automated Quill fixture, not a claim of live Slack validation. +Run `bun run check`, `bun run test`, `bun run check:e2e:coverage`, and both +browsers' smoke/full suites as specified in `docs/agents/testing.md`. Coverage +entries use the existing stable behavior IDs; no baseline behavior is removed. diff --git a/src/adapters/chrome/background/router/MessageRouter.ts b/src/adapters/chrome/background/router/MessageRouter.ts index ce9f5e1eb..b7e6c01a3 100644 --- a/src/adapters/chrome/background/router/MessageRouter.ts +++ b/src/adapters/chrome/background/router/MessageRouter.ts @@ -40,6 +40,7 @@ import { } from "@core/domain/error"; import { DomainSettingsCache } from "../config/DomainSettingsCache"; import type { BackgroundServiceWorker } from "../BackgroundServiceWorker"; +import type { PredictionConfigOverride } from "../PredictionTypes"; import { HandlerRegistry } from "./HandlerRegistry"; import { mapRuntimeError } from "./RuntimeErrorMapper"; @@ -307,22 +308,16 @@ export class MessageRouter { }, }; + let configOverride: PredictionConfigOverride | undefined; + if (domainSettings.hasNumSuggestionsOverride) { + configOverride = { numSuggestions: domainSettings.numSuggestions }; + } + if (request.context.suppressAutoCapitalize === true) { + configOverride = { ...configOverride, suppressAutoCapitalize: true }; + } + await rethrowAs( - () => - worker.runPrediction( - predictRequestMessage, - domainSettings.hasNumSuggestionsOverride || - request.context.suppressAutoCapitalize === true - ? { - ...(domainSettings.hasNumSuggestionsOverride - ? { numSuggestions: domainSettings.numSuggestions } - : {}), - ...(request.context.suppressAutoCapitalize === true - ? { suppressAutoCapitalize: true } - : {}), - } - : undefined, - ), + () => worker.runPrediction(predictRequestMessage, configOverride), (cause) => new PredictorError("Failed to run prediction", { code: "message_run_prediction_failed", diff --git a/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts b/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts index 14c161455..b698c0670 100644 --- a/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts +++ b/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts @@ -8,9 +8,6 @@ export type CodeContext = "prose" | "code" | "protected" | "unknown"; type SelectionRange = Pick; type ScopedSelectionRoot = ShadowRoot & { getSelection?: () => Selection | null }; -type ComposedSelection = Selection & { - getComposedRanges?: (options: { shadowRoots: ShadowRoot[] }) => SelectionRange[]; -}; function parentAcrossShadowRoot(node: Node): Node | null { if (node.parentNode) return node.parentNode; @@ -30,7 +27,7 @@ function ancestorContext(node: Node): CodeContext | null { } function readSelectionRange(element: HTMLElement): SelectionRange | null { - const docSelection: ComposedSelection | null = element.ownerDocument.getSelection(); + const docSelection = element.ownerDocument.getSelection(); if (!docSelection) return null; const roots: ShadowRoot[] = []; diff --git a/src/adapters/chrome/content-script/suggestions/MeasurementEditingContext.ts b/src/adapters/chrome/content-script/suggestions/MeasurementEditingContext.ts index c3b39054a..b177912e0 100644 --- a/src/adapters/chrome/content-script/suggestions/MeasurementEditingContext.ts +++ b/src/adapters/chrome/content-script/suggestions/MeasurementEditingContext.ts @@ -6,7 +6,6 @@ export function measurementEditingContext( element: HTMLElement, ): GrammarHints["measurementContext"] { if (resolveCodeContext(element) !== "prose") return "protected"; - if (element.getAttribute("aria-readonly") === "true") return "protected"; if ( /(?:^|\s)(?:current-password|new-password|one-time-code)(?:\s|$)/.test( element.getAttribute("autocomplete") ?? "", diff --git a/tests/CodeContextGrammar.test.ts b/tests/CodeContextGrammar.test.ts index c1493bd0d..dc381aa16 100644 --- a/tests/CodeContextGrammar.test.ts +++ b/tests/CodeContextGrammar.test.ts @@ -1,15 +1,12 @@ import { afterEach, expect, jest, test } from "bun:test"; +import { createEditor, setCaret as select } from "./codeContextTestUtils"; import { GrammarRuleEngine } from "../src/core/domain/grammar/GrammarRuleEngine"; import { GRAMMAR_RULE_CATALOG } from "../src/core/domain/grammar/ruleCatalog"; import { measurementEditingContext } from "../src/adapters/chrome/content-script/suggestions/MeasurementEditingContext"; import { SuggestionGrammarCoordinator } from "../src/adapters/chrome/content-script/suggestions/SuggestionGrammarCoordinator"; function fixture(): { root: HTMLDivElement; prose: Text; code: Text } { - const root = document.createElement("div"); - root.setAttribute("contenteditable", "true"); - Object.defineProperty(root, "isContentEditable", { configurable: true, value: true }); - root.innerHTML = '

teh

teh
'; - document.body.append(root); + const root = createEditor('

teh

teh
'); return { root, prose: root.firstElementChild!.firstChild as Text, @@ -17,14 +14,6 @@ function fixture(): { root: HTMLDivElement; prose: Text; code: Text } { }; } -function select(node: Text): void { - const range = document.createRange(); - range.setStart(node, node.length); - range.collapse(true); - document.getSelection()!.removeAllRanges(); - document.getSelection()!.addRange(range); -} - function coordinator(enabledGrammarRules?: string[]): SuggestionGrammarCoordinator { return new SuggestionGrammarCoordinator({ enabledGrammarRules: diff --git a/tests/CodeContextResolver.test.ts b/tests/CodeContextResolver.test.ts index 0a662c8d8..2277b4315 100644 --- a/tests/CodeContextResolver.test.ts +++ b/tests/CodeContextResolver.test.ts @@ -1,48 +1,14 @@ import { afterEach, expect, jest, test } from "bun:test"; +import { createEditor as editor, setCaret as caret, withProperty } from "./codeContextTestUtils"; import { resolveCodeContext } from "../src/adapters/chrome/content-script/suggestions/CodeContextResolver"; import { measurementEditingContext } from "../src/adapters/chrome/content-script/suggestions/MeasurementEditingContext"; -function editor(html: string, doc: Document = document): HTMLDivElement { - const element = doc.createElement("div"); - element.setAttribute("contenteditable", "true"); - // jsdom does not implement inherited isContentEditable. - Object.defineProperty(element, "isContentEditable", { configurable: true, value: true }); - element.innerHTML = html; - doc.body.append(element); - return element; -} - -function caret(node: Node, offset = node.textContent?.length ?? 0): void { - const doc = node.ownerDocument ?? document; - const selection = doc.getSelection(); - if (!selection) throw new Error("Missing fixture selection"); - const range = doc.createRange(); - range.setStart(node, offset); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); -} - function text(element: Element): Text { const node = element.firstChild; if (!node || node.nodeType !== 3) throw new Error("Missing fixture text"); return node as Text; } -function withProperty(target: object, name: string, value: unknown, run: () => void): void { - // Instance spies on jsdom's inherited Document methods were ineffective in - // Bun CI. Install an own property and restore its exact previous descriptor. - const previous = Object.getOwnPropertyDescriptor(target, name); - Object.defineProperty(target, name, { configurable: true, writable: true, value }); - try { - expect(Reflect.get(target, name)).toBe(value); - run(); - } finally { - if (previous) Object.defineProperty(target, name, previous); - else Reflect.deleteProperty(target, name); - } -} - afterEach(() => { jest.restoreAllMocks(); document.getSelection()?.removeAllRanges(); diff --git a/tests/CodeContextShadow.test.ts b/tests/CodeContextShadow.test.ts index af18c825c..07d12c4c8 100644 --- a/tests/CodeContextShadow.test.ts +++ b/tests/CodeContextShadow.test.ts @@ -1,28 +1,14 @@ import { expect, jest, test } from "bun:test"; +import { createEditor, withProperty } from "./codeContextTestUtils"; import { resolveCodeContext } from "../src/adapters/chrome/content-script/suggestions/CodeContextResolver"; type CaretRange = Pick; -function withProperty(target: object, name: string, value: unknown, run: () => void): void { - const previous = Object.getOwnPropertyDescriptor(target, name); - Object.defineProperty(target, name, { configurable: true, writable: true, value }); - try { - expect(Reflect.get(target, name)).toBe(value); - run(); - } finally { - if (previous) Object.defineProperty(target, name, previous); - else Reflect.deleteProperty(target, name); - } -} - function fixture() { const host = document.createElement("div"); document.body.append(host); const shadow = host.attachShadow({ mode: "open" }); - const root = document.createElement("div"); - root.setAttribute("contenteditable", "true"); - Object.defineProperty(root, "isContentEditable", { value: true }); - root.innerHTML = '

prose

code
'; + const root = createEditor('

prose

code
'); shadow.append(root); const prose = root.firstElementChild!.firstChild!; const code = root.lastElementChild!.firstChild!; @@ -106,3 +92,35 @@ test("a throwing composed selection API does not fall back to a different caret" }); }); }); + +test("code-context fixtures restore inherited properties after exceptions", () => { + const target = Object.create({ value: "inherited" }) as { value: string }; + expect(() => + withProperty(target, "value", "temporary", () => { + expect(target.value).toBe("temporary"); + throw new Error("fixture failure"); + }), + ).toThrow("fixture failure"); + expect(Object.hasOwn(target, "value")).toBe(false); + expect(target.value).toBe("inherited"); +}); + +test("code-context fixtures restore nested overrides and exact accessor descriptors", () => { + const target = {}; + Object.defineProperty(target, "value", { + configurable: true, + enumerable: false, + get: () => "original", + }); + const original = Object.getOwnPropertyDescriptor(target, "value"); + withProperty(target, "value", "outer", () => { + expect(() => + withProperty(target, "value", "inner", () => { + throw new Error("nested failure"); + }), + ).toThrow("nested failure"); + expect(Reflect.get(target, "value")).toBe("outer"); + }); + expect(Object.getOwnPropertyDescriptor(target, "value")).toEqual(original); + expect(Reflect.get(target, "value")).toBe("original"); +}); diff --git a/tests/CodePredictionCapitalization.test.ts b/tests/CodePredictionCapitalization.test.ts index 7b44097e0..f82100b99 100644 --- a/tests/CodePredictionCapitalization.test.ts +++ b/tests/CodePredictionCapitalization.test.ts @@ -1,4 +1,6 @@ import { afterEach, describe, expect, jest, test } from "bun:test"; +import type { ContentScriptPredictRequestContext } from "../src/core/domain/messageTypes"; +import { createEditor, setCaret as caret, withProperty } from "./codeContextTestUtils"; import { Capitalization } from "../src/adapters/chrome/background/CapitalizationHelper"; import { PredictionInputProcessor } from "../src/adapters/chrome/background/PredictionInputProcessor"; import { PresageHandler } from "../src/adapters/chrome/background/PresageHandler"; @@ -27,15 +29,6 @@ function backend(autoCapitalize = true) { return { handler, orchestrator: new PredictionOrchestrator(handler) }; } -function caret(node: Node): void { - const range = document.createRange(); - range.setStart(node, node.textContent?.length ?? 0); - range.collapse(true); - const selection = document.getSelection()!; - selection.removeAllRanges(); - selection.addRange(range); -} - describe("code prediction capitalization", () => { test("code suppression is request-local and does not force lowercase", async () => { mod.PresageCallback.predictions = ["was", "WasmAPI", "camelCase"]; @@ -125,12 +118,8 @@ describe("code prediction capitalization", () => { }); test("the live caret supplies suppression for code and restores prose in the same host", () => { - const root = document.createElement("div"); - root.setAttribute("contenteditable", "true"); - Object.defineProperty(root, "isContentEditable", { value: true }); - root.innerHTML = '

what . wa

what . wa
'; - document.body.append(root); - const getPrediction = jest.fn(); + const root = createEditor('

what . wa

what . wa
'); + const getPrediction = jest.fn<(context: ContentScriptPredictRequestContext) => void>(); const coordinator = new SuggestionPredictionCoordinator({ debounceByAction: { insert: 0, delete: 0, other: 0 }, getPrediction, @@ -170,15 +159,10 @@ describe("code prediction capitalization", () => { }); test("content messaging forwards the code flag without persisting it", () => { - const descriptor = Object.getOwnPropertyDescriptor(globalThis, "chrome"); const sendMessage = jest.fn((message: { context: { suppressAutoCapitalize?: boolean } }) => Promise.resolve(message), ); - Object.defineProperty(globalThis, "chrome", { - configurable: true, - value: { runtime: { sendMessage } }, - }); - try { + withProperty(globalThis, "chrome", { runtime: { sendMessage } }, () => { const handler = new ContentMessageHandler({ getEnabled: () => true, setEnabled: () => {}, @@ -201,9 +185,6 @@ describe("code prediction capitalization", () => { expect(sendMessage.mock.calls.at(-1)?.[0].context.suppressAutoCapitalize).toBe(true); handler.handleGetPrediction(request); expect(sendMessage.mock.calls.at(-1)?.[0].context.suppressAutoCapitalize).toBeUndefined(); - } finally { - if (descriptor) Object.defineProperty(globalThis, "chrome", descriptor); - else Reflect.deleteProperty(globalThis, "chrome"); - } + }); }); }); diff --git a/tests/background.routing.test.ts b/tests/background.routing.test.ts index 2a109a5bb..d36e39488 100644 --- a/tests/background.routing.test.ts +++ b/tests/background.routing.test.ts @@ -657,6 +657,65 @@ describe("background routing and lifecycle", () => { ); }); + test("onMessage combines code casing suppression with site suggestion-count overrides", async () => { + const harness = await loadBackgroundHarness({ + language: "en_US", + [KEY_SITE_PROFILES]: { "example.com": { language: "en_US", numSuggestions: 4 } }, + }); + const runPrediction = jest + .spyOn(harness.module.BackgroundServiceWorker.prototype, "runPrediction") + .mockResolvedValue(undefined); + const cases = [ + ["plain.example", undefined, undefined], + ["example.com", undefined, { numSuggestions: 4 }], + ["plain.example", true, { suppressAutoCapitalize: true }], + ["example.com", true, { numSuggestions: 4, suppressAutoCapitalize: true }], + ["plain.example", false, undefined], + ["example.com", false, { numSuggestions: 4 }], + ["plain.example", "true", undefined], + ["example.com", 1, { numSuggestions: 4 }], + ] as const; + try { + for (const [domain, suppressAutoCapitalize, expected] of cases) { + runPrediction.mockClear(); + harness.getDomain.mockReturnValue(domain); + const reply = await new Promise((resolve) => { + harness.onMessage( + { + command: CMD_CONTENT_SCRIPT_PREDICT_REQ, + context: { + text: "what . wa", + nextChar: "", + afterCursorTokenSuffix: "s", + lang: "en_US", + suggestionId: 1, + requestId: 2, + suppressAutoCapitalize, + }, + }, + { tab: { id: 77, url: `https://${domain}` } as chrome.tabs.Tab, frameId: 3 }, + resolve, + ); + }); + expect(reply).toEqual({ ok: true }); + expect(runPrediction).toHaveBeenCalledTimes(1); + expect(runPrediction).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + text: "what . wa", + afterCursorTokenSuffix: "s", + tabId: 77, + frameId: 3, + }), + }), + expected, + ); + } + } finally { + runPrediction.mockRestore(); + } + }); + test("onMessage applies site profile language and suggestion count override", async () => { const harness = await loadBackgroundHarness({ [KEY_SITE_PROFILES]: { diff --git a/tests/codeContextTestUtils.ts b/tests/codeContextTestUtils.ts new file mode 100644 index 000000000..bcc5e66d7 --- /dev/null +++ b/tests/codeContextTestUtils.ts @@ -0,0 +1,38 @@ +import { expect } from "bun:test"; + +export function createEditor(html: string, doc: Document = document): HTMLDivElement { + const element = doc.createElement("div"); + element.setAttribute("contenteditable", "true"); + // jsdom does not implement inherited isContentEditable. + Object.defineProperty(element, "isContentEditable", { configurable: true, value: true }); + element.innerHTML = html; + doc.body.append(element); + return element; +} + +export function setCaret( + node: Node, + offset = node.nodeType === 3 ? (node.textContent?.length ?? 0) : node.childNodes.length, +): void { + const doc = node.ownerDocument ?? document; + const selection = doc.getSelection(); + if (!selection) throw new Error("Missing fixture selection"); + const range = doc.createRange(); + range.setStart(node, offset); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); +} + +/** Scoped, synchronous override; inherited jsdom methods need own properties in Bun. */ +export function withProperty(target: object, name: string, value: unknown, run: () => void): void { + const previous = Object.getOwnPropertyDescriptor(target, name); + Object.defineProperty(target, name, { configurable: true, writable: true, value }); + try { + expect(Reflect.get(target, name)).toBe(value); + run(); + } finally { + if (previous) Object.defineProperty(target, name, previous); + else Reflect.deleteProperty(target, name); + } +} diff --git a/tests/e2e/coverage-matrix.json b/tests/e2e/coverage-matrix.json index d586e145c..d4b509e15 100644 --- a/tests/e2e/coverage-matrix.json +++ b/tests/e2e/coverage-matrix.json @@ -2431,6 +2431,16 @@ "layer": "unit", "file": "tests/CodeContextShadow.test.ts", "test": "a throwing composed selection API does not fall back to a different caret" + }, + { + "layer": "unit", + "file": "tests/CodeContextShadow.test.ts", + "test": "code-context fixtures restore inherited properties after exceptions" + }, + { + "layer": "unit", + "file": "tests/CodeContextShadow.test.ts", + "test": "code-context fixtures restore nested overrides and exact accessor descriptors" } ] }, @@ -2478,6 +2488,11 @@ "layer": "e2e-full", "file": "tests/e2e/full.e2e.test.ts", "test": "Quill code predictions keep lowercase through Tab and restore prose casing" + }, + { + "layer": "unit", + "file": "tests/background.routing.test.ts", + "test": "onMessage combines code casing suppression with site suggestion-count overrides" } ] } diff --git a/tests/e2e/full.e2e.test.ts b/tests/e2e/full.e2e.test.ts index 133fff9be..120d42c45 100644 --- a/tests/e2e/full.e2e.test.ts +++ b/tests/e2e/full.e2e.test.ts @@ -1,3 +1,4 @@ +import type Quill from "quill"; import type { Browser, Page } from "puppeteer"; import path from "path"; import * as fs from "fs"; @@ -2966,21 +2967,7 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { await waitForInputReady(page, QUILL_SELECTOR); await page.focus(QUILL_SELECTOR); await page.evaluate(() => { - const quill = ( - window as typeof window & { - __testQuill?: { - setText: (text: string, source?: string) => void; - formatLine: ( - index: number, - length: number, - name: string, - value: boolean, - source?: string, - ) => void; - setSelection: (index: number, length: number, source?: string) => void; - }; - } - ).__testQuill; + const quill = (window as typeof window & { __testQuill?: Quill }).__testQuill; if (!quill) throw new Error("Quill test instance not found"); quill.setText("what . \nwhat . \n", "silent"); quill.formatLine(0, 1, "code-block", true, "api"); @@ -2992,14 +2979,7 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { if (expected === "Was") { await page.keyboard.press("Escape"); await page.evaluate(() => { - const quill = ( - window as typeof window & { - __testQuill?: { - getText: () => string; - setSelection: (index: number, length: number, source?: string) => void; - }; - } - ).__testQuill; + const quill = (window as typeof window & { __testQuill?: Quill }).__testQuill; if (!quill) throw new Error("Quill test instance not found"); quill.setSelection(quill.getText().indexOf("\n") + 1 + "what . ".length, 0, "api"); }); @@ -3008,7 +2988,7 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { const index = await waitUntil( `Quill offers ${expected} with context-correct casing`, async () => { - const suggestions = await waitForVisibleSuggestionTexts(page); + const suggestions = await getVisibleSuggestionTexts(page); const found = suggestions.findIndex((text) => text.trim() === expected); return found >= 0 ? { value: found } : false; }, @@ -3020,9 +3000,7 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { `Quill inserts ${expected} without changing code casing`, async () => page.evaluate((inProse) => { - const quill = ( - window as typeof window & { __testQuill?: { getText: () => string } } - ).__testQuill; + const quill = (window as typeof window & { __testQuill?: Quill }).__testQuill; const lines = quill ?.getText() .replace(/\u00a0/g, " ")