diff --git a/docs/automatic-code-context.md b/docs/automatic-code-context.md new file mode 100644 index 000000000..de0890e76 --- /dev/null +++ b/docs/automatic-code-context.md @@ -0,0 +1,73 @@ +# Automatic rich-text code protection + +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. + +## Detection + +`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. + +Monospace fonts, `spellcheck=false`, `data-gramm=false`, generic `.code` or +`language-*` classes, and program-looking text are not standalone code signals. + +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. + +## Grammar and prediction behavior + +`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. + +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. + +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. + +## 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. + +Run the focused tests: + +```sh +bun test tests/CodeContextResolver.test.ts tests/CodeContextGrammar.test.ts tests/CodeContextShadow.test.ts tests/CodePredictionCapitalization.test.ts +``` + +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/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..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,14 +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 - ? { numSuggestions: domainSettings.numSuggestions } - : 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/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/CodeContextResolver.ts b/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts new file mode 100644 index 000000000..b698c0670 --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts @@ -0,0 +1,105 @@ +/** 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 }; + +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(); + 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..b177912e0 100644 --- a/src/adapters/chrome/content-script/suggestions/MeasurementEditingContext.ts +++ b/src/adapters/chrome/content-script/suggestions/MeasurementEditingContext.ts @@ -1,15 +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 (element.getAttribute("aria-readonly") === "true") return "protected"; + if (resolveCodeContext(element) !== "prose") return "protected"; if ( /(?:^|\s)(?:current-password|new-password|one-time-code)(?:\s|$)/.test( element.getAttribute("autocomplete") ?? "", @@ -23,13 +19,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/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/CodeContextGrammar.test.ts b/tests/CodeContextGrammar.test.ts new file mode 100644 index 000000000..dc381aa16 --- /dev/null +++ b/tests/CodeContextGrammar.test.ts @@ -0,0 +1,119 @@ +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 = createEditor('

teh

teh
'); + return { + root, + prose: root.firstElementChild!.firstChild as Text, + code: root.lastElementChild!.firstChild as Text, + }; +} + +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, prose, code } = fixture(); + const grammar = coordinator(); + // 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 of [prose, code]) { + const expectedContext = node === prose ? "prose" : "protected"; + select(node); + const edit = grammar.run({ + beforeCursor: "teh ", + afterCursor: "", + inputAction: "insert", + triggers: [trigger], + measurementContext: measurementEditingContext(root), + }); + 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(); + } + } + expect(process).toHaveBeenCalledTimes(8); + } finally { + process.mockRestore(); + } +}); + +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..2277b4315 --- /dev/null +++ b/tests/CodeContextResolver.test.ts @@ -0,0 +1,244 @@ +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 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('

prose

code
'); + inner.append(root); + let node = text(root.lastElementChild!); + const getComposedRanges = jest.fn(() => [ + { startContainer: node, startOffset: 2, endContainer: node, endOffset: 2 }, + ]); + 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", () => { + 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); + range.collapse(true); + const getComposedRanges = jest.fn(() => [range]); + 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

"); + caret(text(root.firstElementChild!)); + expect(resolveCodeContext(root)).toBe("prose"); + const getSelection = jest.fn(() => { + throw new Error("Selection unavailable during reconciliation"); + }); + withProperty(document, "getSelection", getSelection, () => { + expect(resolveCodeContext(root)).toBe("unknown"); + expect(measurementEditingContext(root)).toBe("protected"); + expect(getSelection).toHaveBeenCalledTimes(2); + }); + expect(resolveCodeContext(root)).toBe("prose"); +}); diff --git a/tests/CodeContextShadow.test.ts b/tests/CodeContextShadow.test.ts new file mode 100644 index 000000000..07d12c4c8 --- /dev/null +++ b/tests/CodeContextShadow.test.ts @@ -0,0 +1,126 @@ +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 fixture() { + const host = document.createElement("div"); + document.body.append(host); + const shadow = host.attachShadow({ mode: "open" }); + const root = createEditor('

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, 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(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(); + }); + }); +}); + +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 new file mode 100644 index 000000000..f82100b99 --- /dev/null +++ b/tests/CodePredictionCapitalization.test.ts @@ -0,0 +1,190 @@ +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"; +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) }; +} + +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 = createEditor('

what . wa

what . wa
'); + const getPrediction = jest.fn<(context: ContentScriptPredictRequestContext) => void>(); + 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 sendMessage = jest.fn((message: { context: { suppressAutoCapitalize?: boolean } }) => + Promise.resolve(message), + ); + withProperty(globalThis, "chrome", { runtime: { sendMessage } }, () => { + 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(); + }); + }); +}); 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/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-baseline-ids.json b/tests/e2e/coverage-baseline-ids.json index d5d60682b..0fbbc6d0b 100644 --- a/tests/e2e/coverage-baseline-ids.json +++ b/tests/e2e/coverage-baseline-ids.json @@ -151,6 +151,10 @@ "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", + "grammar_code_prediction_casing" ] } diff --git a/tests/e2e/coverage-matrix.json b/tests/e2e/coverage-matrix.json index 28b42046c..d4b509e15 100644 --- a/tests/e2e/coverage-matrix.json +++ b/tests/e2e/coverage-matrix.json @@ -2366,6 +2366,135 @@ "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" + }, + { + "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" + } + ] + }, + { + "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" + } + ] + }, + { + "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" + }, + { + "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 b9ad63df8..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"; @@ -2950,6 +2951,80 @@ 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?: 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"); + 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?: Quill }).__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 getVisibleSuggestionTexts(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?: Quill }).__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 () => {