diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8fd25263..8e3d79e8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -116,6 +116,9 @@ jobs: - name: Install Firefox for Puppeteer if: matrix.browser == 'firefox' run: bunx puppeteer browsers install firefox + - name: Google Docs cross-world fixtures (not live Docs) + if: matrix.browser == 'chrome' + run: bun run test:e2e:docs - run: bun run test:e2e:full --platform=${{ matrix.browser }} env: CI: "true" diff --git a/README.md b/README.md index 546639e1..0b50fcd5 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,9 @@ Site profiles never bypass domain enable/disable logic. If a domain is blocked b ## Compatibility -FluentTyper works on most websites. Some rich text editors (for example Google Docs) can be partially or fully incompatible. +FluentTyper works on most websites, including Google Docs. + +Google Docs support activates on any document edit page when FluentTyper is enabled for docs.google.com. Suggestions are applied through a single synthetic plain-text paste into the editor, and the edit is verified against the document model before local learning records it. See [docs/google-docs-integration.md](docs/google-docs-integration.md) for details and limits. Other canvas-based rich text editors can still be partially or fully incompatible. If you hit an unsupported site, please open a bug report so compatibility can be improved. diff --git a/docs/agents/runtime-features.md b/docs/agents/runtime-features.md index 1969724e..ad2602c9 100644 --- a/docs/agents/runtime-features.md +++ b/docs/agents/runtime-features.md @@ -53,3 +53,11 @@ When adding a user-facing setting: - Production logging should stay minimal, typically warn and error only. - Do not log full user text content. - Guard extra debug logging behind development mode or the existing logging level controls. + +## Google Docs + +- Code lives in `src/adapters/chrome/content-script/google-docs/`. `ContentRuntimeController` creates `GoogleDocsAdapter` only on a top-level Docs edit page; the generic `SuggestionManager` is disabled only inside the hidden `iframe.docs-texteventtarget-iframe`. +- `GoogleDocsMainWorld` runs from `content_script_main_world_start.ts` (MAIN world, `document_start`) and sets `window._docs_annotate_canvas_by_ext` to FluentTyper's own extension ID so Docs exposes `_docs_annotate_getAnnotatedText`. Never impersonate another extension's ID. +- Isolated and MAIN worlds talk only through `CustomEvent`s with JSON string payloads; the bridge exposes no extension APIs to the page. +- Edits are single-use-token transactions: read model, select the minimal range, dispatch one synthetic plain-text paste, verify text. Unverified edits are never retried. +- Tests: `bun test tests/GoogleDocsModel.test.ts tests/GoogleDocsTransaction.test.ts` and `bun run test:e2e:docs` (Chromium fixture with real MAIN/isolated worlds, mocked Docs API). Live check: `bun run test:e2e:docs:live -- --help`. diff --git a/docs/google-docs-integration.md b/docs/google-docs-integration.md new file mode 100644 index 00000000..db7e2d9e --- /dev/null +++ b/docs/google-docs-integration.md @@ -0,0 +1,169 @@ +# Google Docs integration candidate + +Base: `058cde147b78a36e4ab5c3d4e52a4f44cdfe1b74`. Date: 2026-09-15. + +**Status: implemented and locally tested; NOT approved for a production rollout.** +The live Google Docs editor could not be reached in the development environment. +An actual navigation failed with `net::ERR_BLOCKED_BY_ADMINISTRATOR`. Browser +fixtures below are explicitly simulated editors, not a substitute for live validation. + +## Install and enable + +This is a replacement for the earlier experimental patch, not a patch to stack on it. +Apply it to a clean checkout of the base, or let Git perform a reviewed three-way merge +on a newer branch. Do not discard unrelated local work to apply it. + +```sh +git switch -c candidate/google-docs-integration +git apply --check /path/to/fluenttyper-google-docs-v2.patch +git apply /path/to/fluenttyper-google-docs-v2.patch +bun install --frozen-lockfile +bun run check +bun run test +bun run check:e2e:coverage +bun run test:e2e:docs +bun run build --platform=chrome +``` + +For manual testing use a dedicated browser profile and a NEW, EMPTY, DISPOSABLE +document. Load the unpacked build and enable FluentTyper on docs.google.com; the +adapter activates on any document edit URL. The annotation bootstrap runs at +`document_start`, so reload the document after enabling the site. Normal +title/comment helpers stay active. + +Passing a build in `production` mode does not mean this private-API integration is +production-certified. +No new permissions, dependencies, network services, clipboard reads or clipboard +writes are added. Predictions use the existing local backend and its settings. +The MAIN-world bridge exposes no extension APIs to the page. +Known limitation: the keyboard bridge publishes the current single-use token in a DOM +attribute on the input iframe, so a script already running on docs.google.com could forge +an acceptance of a visible suggestion. Such a script can already edit the document through +the same page API; the only extension-side effect is a spurious local learning record. + +## Architecture and feature mapping + +The Google Docs adapter receives logical text and selection from the page-side +annotated-text capability. It supplies explicit text context to the shared +`SuggestionPredictionCoordinator`; no fake textarea is edited to manufacture success. +The background predictor, snippet expansion, language selection, user dictionary, +site configuration, and personalization settings keep their existing code paths. + +| Requirement | Implemented behavior | Evidence / limitation | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Prefix and non-prefix spelling completion | Plan a complete range replacement, including a suffix under a mid-word caret. | Model and cross-world fixtures pass. Predictor replies are stubbed in browser fixtures. | +| Selected-text replacement | Explicit manual invocation supports a bounded forward or reversed selection. | Never autonomously replaces a user's noncollapsed selection. | +| Grammar and rewrites | Reuses the complete configured local grammar catalog; paragraph-scoped triggers and custom caret offsets. | Not a new document-wide AI grammar model. Shared catalog regression suite passes; one automatic correction is browser-tested. | +| Snippets and dynamic variables | Existing background expansion feeds the same pipeline; accepts multiline expansion text without flattening whitespace. | Multiline insertion is fixture-tested. Real background-to-Docs expansion and formatting require live tests. | +| Next-word prediction | Shared coordinator accepts empty-prefix requests and inserts without deleting the following word. | Model and browser fixtures pass. | +| Inline mode | Reuses the owned ghost presenter for a safe suffix at a line end. Fixes the thin-caret 1px width clamp. | Spelling rewrites, midtext, RTL, multiline or ambiguous geometry use the themed menu. Full canvas-mirror inline parity is NOT implemented. | +| Keyboard and mouse | Configured Tab, Enter, Space, arrows, Escape and digit shortcuts; synchronous early key acknowledgment, mouse focus preservation. | Trusted keyboard events cross actual MAIN/isolated contexts in Chromium fixtures. | +| Themes | Existing Shadow DOM menu, typography service and theme variables. | Custom theme regression passes. Exact Docs font/zoom alignment needs live review. | +| Statistics / learning | Existing local services run only after observed model success; deduplicated late acknowledgment and exact last-edit reversal observation. | No claim that event dispatch means acceptance. Undo/redo journal observation is fixture-tested, not native Docs undo grouping. | +| Titles and comments | Generic helper remains active in top-level ordinary editable fields; only the hidden Docs input iframe is excluded. | Real generic SuggestionManager exercised on fixture input and textarea. Actual Docs comment DOM remains a live check. | +| Accessibility / localization | Keyboard access, option semantics, polite selection announcements, visible failure status and nine UI-language translations. | Does not overwrite Docs' editable ARIA attributes. Not screen-reader/WCAG certified. | +| IME | Composition guards across frames, settling delays, key-code 229 avoidance; no acceptance while composing. | Synthetic composition-event fixture only. Native platform IMEs are a release gate. | +| RTL and multiple visible carets | Logical Unicode offsets and grapheme-safe edits; direction-aware menu. A fixed palette avoids guessing a collaborator's caret. | Does not prove visual bidi shaping/caret affinity or identify every local caret. | +| Collaboration | Fresh model/selection/scope/input/interaction checks before selection and before paste; stale work is discarded. | No revision-aware atomic transaction exists in this implementation. Concurrent operation ordering is NOT proven. | +| Document tabs | Pending work is bound to full edit URL, including tab query parameters, and input-object identity. | Same-URL scope changes or private API topology not separately identified. Real multi-tab behavior remains unverified. | +| Tables / footnotes / mixed formatting | Rejects edits crossing exposed object/control markers; minimizes the changed range on grapheme boundaries. | Not a structural document model. Text parity cannot prove structure or formatting preservation. | +| Offline | No new network dependency; offline browser fixture passes. | Does not verify Google Docs' offline cache, save synchronization or persistence. | +| Smart Compose / other extensions | Respects configured preference for visible `aria-controls` native popups. | Canvas Smart Compose and arbitrary third-party overlays are NOT reliably detected. Disable competitors in the initial live test profile. | + +## Live editor quirks (verified in Chrome against real Google Docs) + +- `setSelection` blurs the editable inside `iframe.docs-texteventtarget-iframe` while the + frame itself stays focused. The bridge refocuses the editable before pasting and treats a + blurred editable as active as long as the frame is. +- Docs strips leading/trailing ASCII spaces from a plain-text paste but converts NBSP to a + regular space. Edge spaces are sent as NBSP; the verified model still contains `" "`. +- An unverified write blocks the adapter only until the next trusted user interaction; it is + then forgotten without being retried or learned. + +## Edit transaction invariants + +`GoogleDocsModel.ts` validates metadata, Unicode boundaries and edit ranges. +`GoogleDocsTransaction.ts` owns single-use tokens, model validation, the edit journal, +selection restoration and acknowledgments. `GoogleDocsMainWorld.ts` adapts the private +API and iframe input realm. `GoogleDocsBridgeClient.ts` uses bounded JSON requests. +`GoogleDocsAdapter.ts` connects predictions, grammar, UI and local learning. + +A token contains a fresh full logical model, raw selection, focused input object, +full URL, interaction generation and expiry. Only bounded context crosses to the +content-script prediction adapter. Limits are 2,000,000 UTF-16 code units per document, +16,384 per edit/selection, and 8,192 of context per side. Unsupported states fail closed. + +An edit token is consumed before asynchronous work. The adapter re-reads the model, +selects only the minimum changed span, then rechecks text, selection and identity. +It dispatches **one synthetic plain-text paste** in the input iframe's event realm. +Synthetic paste is still untrusted and may be ignored. Neither `dispatchEvent` nor +`execCommand` return values are accepted as proof of insertion. + +The bridge verifies the expected raw logical text independently. A delayed exact +acknowledgment can recover the session without another paste. An ambiguous write +retains its journal across adapter disable/cancel; it is never blindly retried, +fuzzily relocated, repaired by rewriting a block, or rolled back over user edits. +Native undo/redo shortcuts are not hijacked. The journal observes an exact last-edit +text reversal to reverse local personalization; it does not certify native undo units. + +These checks reduce races but are **not atomic compare-and-swap** against Google's +collaborative model. Text equality does not prove formatting, revision identity, +persistence, or that an independent identical edit was not made concurrently. + +## Automated tests and continuous integration + +```sh +bun run check +bun run test +bun run test:e2e:docs +bun run check:e2e:coverage +bun run test:e2e +bun run test:e2e:full +bun run test:e2e:full --platform=firefox +``` + +The Docs fixture suite compiles the real adapter and shared services, creates MAIN +and isolated Chromium worlds, and sends real browser keyboard events. Only the editor's +annotated API, its canvas model and prediction responses are mocked. It does not load +the packaged extension's service worker. Its URL facade and randomUUID fallback are +strictly fixture code, never included in the extension build. The fixture runs +in-memory; no browser policy or live-site access restriction is bypassed. + +The Chrome full-regression CI job now runs this suite. Coverage matrix and baseline +IDs are updated with unit/integration mappings rather than fictitious live coverage. +There is no equivalent Firefox cross-world fixture yet. Firefox builds and ordinary +regression tests retain their existing path. + +## Real-document check + +```sh +bun run test:e2e:docs:live -- --help +``` + +The supplied operator-assisted script requires an explicit disposable document URL, +a dedicated local profile, an unpacked extension and `--allow-edits`. Authentication +happens in your local browser, not through shared credentials. The script refuses a +nonempty logical document, tests an actual offered completion and native undo/redo, +and asks the operator to verify Saved to Drive before testing reload persistence. +It writes a local report and never retries a failed edit. **It has not been run +against live Google Docs in this environment.** It is a smoke check, not the full matrix. + +Verified live in Chrome (2026-09-15): the annotated API activates for FluentTyper's own +extension ID, suggestions render at the caret, Tab acceptance is verified as applied, and +native undo/redo work. Still unverified: Edge/Firefox, all supported keyboard settings, snippets/dynamic variables, +user dictionaries, language/site profiles, native undo/redo, mixed formatting and +links, headings/lists/tables/footnotes, multiple tabs, two collaborating accounts, +disjoint and overlapping remote edits, zoom/scroll, RTL, native IMEs, screen readers, +Smart Compose/competing extensions, offline/reconnection, save and reload. Do not +mark missing evidence passed because a fixture or build succeeded. + +## References + +- Harper's implementation overview: https://writewithharper.com/docs/contributors/chrome-extension +- Harper's page bridge: https://github.com/Automattic/harper/blob/master/packages/chrome-plugin/public/google-docs-bridge.js +- Chrome content-script world/lifecycle documentation: https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts + +The annotated-text surface is not a stable public Google Docs editing API. Access +for FluentTyper's own extension IDs and compatibility with current Docs must be +validated in a real browser. This implementation never impersonates Harper or another +extension and never overwrites an already-set annotation bootstrap flag. diff --git a/package.json b/package.json index 1ccef879..e1c25ecc 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "fix": "bun run lint:fix && bun run format", "format": "prettier . --cache --write", "format:check": "prettier . --cache --check", - "bump": "bun pm version" + "bump": "bun pm version", + "test:e2e:docs": "bun test --timeout=12000 tests/e2e/google-docs.e2e.test.ts", + "test:e2e:docs:live": "bun scripts/test-google-docs-live.ts" }, "repository": { "type": "git", diff --git a/scripts/test-google-docs-live.ts b/scripts/test-google-docs-live.ts new file mode 100644 index 00000000..0b795096 --- /dev/null +++ b/scripts/test-google-docs-live.ts @@ -0,0 +1,187 @@ +/** Operator-assisted smoke check on a NEW, EMPTY, DISPOSABLE real Google Doc. + * No mocked API, test predictor, clipboard access or document-API edits are used. + * The operator authenticates locally and explicitly approves persistence testing. + */ +import process from "node:process"; +import path from "node:path"; +import { mkdir, writeFile } from "node:fs/promises"; +import { createInterface } from "node:readline/promises"; +import puppeteer, { type Page } from "puppeteer"; +import { readModel } from "../src/adapters/chrome/content-script/google-docs/GoogleDocsModel"; + +const args = process.argv.slice(2); +const value = (key: string) => args.find((arg) => arg.startsWith(`${key}=`))?.slice(key.length + 1); +if (args.includes("--help")) { + console.log(`Real Google Docs operator-assisted smoke test (writes to the chosen document). + +bun run test:e2e:docs:live -- \\ + --url=https://docs.google.com/document/d/DISPOSABLE_ID/edit \\ + --extension=/absolute/path/to/unpacked/chrome-build \\ + --profile=/absolute/path/to/DEDICATED-test-profile \\ + --allow-edits + +Use a new empty, single-tab document and a dedicated browser profile. Configure +FluentTyper for English, enable it on docs.google.com, and enable Tab acceptance. +The browser must permit unpacked extensions (Chrome for Testing is suitable). +PUPPETEER_EXECUTABLE_PATH may specify your browser executable. +The script refuses nonempty documents. It checks a real offered completion, +native undo/redo, then asks you to verify the Saved-to-Drive indicator before reload. +It does not certify formatting, collaboration, every IME or other document topologies. +No Google credentials, cookies, document text or account data are uploaded.`); + process.exit(0); +} + +async function main(): Promise { + const urlValue = value("--url"); + const extensionValue = value("--extension"); + const profileValue = value("--profile"); + if (!args.includes("--allow-edits") || !urlValue || !extensionValue || !profileValue) { + throw new Error( + "Explicit --allow-edits, --url, --extension and --profile are required. Use --help.", + ); + } + const url = new URL(urlValue); + if ( + url.origin !== "https://docs.google.com" || + !/^\/document\/(?:u\/\d+\/)?d\/[\w-]+\/edit$/.test(url.pathname) + ) { + throw new Error("A real Google Docs document edit URL is required."); + } + const extension = path.resolve(extensionValue); + const profile = path.resolve(profileValue); + await mkdir(profile, { recursive: true }); + const report: { checks: Array<{ name: string; status: string }>; error?: string } = { + checks: [], + }; + const cli = createInterface({ input: process.stdin, output: process.stdout }); + const browser = await puppeteer.launch({ + headless: false, + userDataDir: profile, + enableExtensions: [extension], + }); + try { + const page = await browser.newPage(); + await page.goto(url.href, { waitUntil: "domcontentloaded" }); + await cli.question( + "Sign in locally if necessary. Set up FluentTyper, open this disposable document, and click its empty writing area. Then press Enter here. ", + ); + const current = new URL(page.url()); + if (current.origin !== url.origin || current.pathname !== url.pathname) { + throw new Error( + "The selected page is not the specified test document. No typing was attempted.", + ); + } + const before = await read(page); + if (before.text !== "" || before.anchor !== before.focus) { + throw new Error("Refusing to edit: the document is not empty with a collapsed caret."); + } + report.checks.push({ name: "real annotated API and empty document", status: "passed" }); + const frame = await page.$("iframe.docs-texteventtarget-iframe"); + const editor = await frame?.contentFrame(); + if (!editor) throw new Error("Docs input frame is unavailable."); + await page.bringToFront(); + await editor.evaluate(() => { + const target = document.querySelector('[contenteditable="true"]'); + if (!target) throw new Error("Docs input element is unavailable."); + target.focus(); + }); + await page.keyboard.type("hel"); + await waitForText(page, "hel"); + await page.waitForFunction( + () => { + const frame = document.querySelector("iframe.docs-texteventtarget-iframe"); + const state = JSON.parse(frame?.getAttribute("data-ft-docs-key-state") ?? "null"); + return state && Array.isArray(state.keys) && state.keys.includes("Tab"); + }, + { timeout: 15000 }, + ); + const offered = await page.evaluate(() => { + const menu = document.getElementById("ft-menu--1"); + const selected = menu?.shadowRoot?.querySelector( + '[aria-selected="true"] .ft-suggestion-label', + ); + if (menu && getComputedStyle(menu).display !== "none" && selected?.textContent) + return selected.textContent; + const ghost = + document.querySelector( + '.ft-suggestion-inline[data-ft-suggestion-owned="true"]', + ) ?? document.querySelector(".ft-suggestion-inline"); + return ghost?.textContent ? "hel" + ghost.textContent : null; + }); + if (!offered || offered === "hel") + throw new Error("No actual, usable FluentTyper suggestion is visible."); + await page.keyboard.press("Tab"); + await waitForText(page, offered); + report.checks.push({ name: "actual prediction and Tab acceptance", status: "passed" }); + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+z`); + await waitForText(page, "hel"); + report.checks.push({ name: "native undo returns the typed trigger", status: "passed" }); + await page.keyboard.press(`${modifier}+Shift+z`); + await waitForText(page, offered); + report.checks.push({ name: "native redo restores the completion", status: "passed" }); + const saved = await cli.question( + 'Verify Google Docs shows the document saved to Drive. Type "saved" to allow reload; anything else stops without reloading: ', + ); + if (saved.trim().toLowerCase() !== "saved") + throw new Error("Persistence check not approved; document was not reloaded."); + await page.reload({ waitUntil: "domcontentloaded" }); + await waitForText(page, offered); + report.checks.push({ + name: "text persists after operator-confirmed save and reload", + status: "passed", + }); + } catch (error) { + // Local diagnostic only. Do not publish a user's URL/account text in shared CI logs. + report.error = error instanceof Error ? error.message : String(error); + throw error; + } finally { + await writeFile( + path.join(profile, "fluenttyper-docs-smoke-report.json"), + JSON.stringify(report, null, 2) + "\n", + ); + cli.close(); + await browser.close(); + } + console.log( + "The narrow real-document smoke checks passed. The full release matrix remains separate.", + ); +} + +async function read(page: Page) { + const value = await page.evaluate(async () => { + const get = ( + window as unknown as { + _docs_annotate_getAnnotatedText?: () => Promise<{ + getText(): unknown; + getSelection(): unknown; + }>; + } + )._docs_annotate_getAnnotatedText; + if (typeof get !== "function") + throw new Error("Annotated text capability is unavailable for this extension ID."); + const api = await get(); + return { raw: api.getText(), selection: api.getSelection() }; + }); + const model = readModel(value.raw, value.selection); + if (!model) throw new Error("Unsupported or unavailable Google Docs text/selection metadata."); + return model; +} +async function waitForText(page: Page, expected: string): Promise { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + try { + if ((await read(page)).text === expected) return; + } catch { + // A reload can temporarily make the capability unavailable. No edits are retried. + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error( + "Expected logical text was not observed; no automatic retry or repair was attempted.", + ); +} +void main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/src/adapters/chrome/content-script/ContentRuntimeController.ts b/src/adapters/chrome/content-script/ContentRuntimeController.ts index b3ecaa92..5ff4a60b 100644 --- a/src/adapters/chrome/content-script/ContentRuntimeController.ts +++ b/src/adapters/chrome/content-script/ContentRuntimeController.ts @@ -13,6 +13,10 @@ import { ThemeApplicator } from "./ThemeApplicator"; import { SuggestionManager } from "./SuggestionManager"; import type { EarlyTabAcceptResult } from "./suggestions/SuggestionManagerRuntime"; +import { GoogleDocsAdapter } from "./google-docs/GoogleDocsAdapter"; +import { DOCS_SESSION_ID } from "./google-docs/GoogleDocsModel"; +import { isGoogleDocsPage, isGoogleDocsInputFrame } from "./google-docs/GoogleDocsEnvironment"; + const logger = createLogger("ContentRuntimeController"); export class ContentRuntimeController { @@ -22,6 +26,7 @@ export class ContentRuntimeController { private static readonly MAX_MUTATION_BATCH_SIZE = 200; private static readonly MAX_MUTATION_ROOTS = 64; + private googleDocs: GoogleDocsAdapter | null = null; public suggestionManager: SuggestionManager | null = null; public config: SetConfigContext = { enabled: false, @@ -145,9 +150,11 @@ export class ContentRuntimeController { } this.config.lang = lang; this.suggestionManager?.updateLangConfig(this.config.lang); + this.googleDocs?.updateLanguage(this.config.lang); } triggerActiveSuggestion(): void { + this.googleDocs?.triggerActiveSuggestion(); this.suggestionManager?.triggerActiveSuggestion(); } @@ -182,6 +189,10 @@ export class ContentRuntimeController { }); return; } + if (context.suggestionId === DOCS_SESSION_ID && this.googleDocs) { + this.googleDocs.fulfillPrediction(context); + return; + } this.suggestionManager?.fulfillPrediction(context); } @@ -222,9 +233,11 @@ export class ContentRuntimeController { enable(): void { logger.info("Enabling content runtime"); - if (!this.suggestionManager) { + if (!this.suggestionManager || (isGoogleDocsPage() && !this.googleDocs)) { + this.suggestionManager?.detachAllHelpers(); this.initializeSuggestionManager(); } + this.googleDocs?.start(); this.suggestionManager?.queryAndAttachHelper(); this.suggestionManager?.triggerActiveSuggestion(); this.attachMutationObserver(); @@ -235,6 +248,8 @@ export class ContentRuntimeController { } disable(): void { + this.googleDocs?.dispose(); + this.googleDocs = null; logger.info("Disabling content runtime"); if (this.pendingRestartTimer !== null) { clearTimeout(this.pendingRestartTimer); @@ -388,8 +403,9 @@ export class ContentRuntimeController { minWordLengthToPredict: this.config.minWordLengthToPredict, generation, }); - this.suggestionManager = new SuggestionManager({ - selectors: ContentRuntimeController.SELECTORS, + const managerOptions = { + // Only Docs' hidden input iframe is excluded; titles/comments keep the normal helper. + selectors: isGoogleDocsInputFrame() ? ":not(*)" : ContentRuntimeController.SELECTORS, minWordLengthToPredict: this.config.minWordLengthToPredict, autocomplete: this.config.autocomplete, autocompleteOnEnter: this.config.autocompleteOnEnter, @@ -408,7 +424,9 @@ export class ContentRuntimeController { runtimeGeneration: generation, }), onShadowRootDiscovered: this.registerShadowRoot.bind(this), - }); + }; + this.suggestionManager = new SuggestionManager(managerOptions); + if (isGoogleDocsPage()) this.googleDocs = new GoogleDocsAdapter(managerOptions); this.reportRuntimeActivity(); } diff --git a/src/adapters/chrome/content-script/google-docs/GoogleDocsAdapter.ts b/src/adapters/chrome/content-script/google-docs/GoogleDocsAdapter.ts new file mode 100644 index 00000000..254a4eb8 --- /dev/null +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsAdapter.ts @@ -0,0 +1,581 @@ +import { LANG_SEPARATOR_CHARS_REGEX } from "@core/domain/lang"; +import type { GrammarEventType } from "@core/domain/grammar/types"; +import type { PredictionInputAction } from "@core/domain/messageTypes"; +import { + SuggestionPredictionCoordinator, + type PredictionSessionState, +} from "../suggestions/SuggestionPredictionCoordinator"; +import { SuggestionGrammarCoordinator } from "../suggestions/SuggestionGrammarCoordinator"; +import { SuggestionTelemetryService } from "../suggestions/SuggestionTelemetryService"; +import { SuggestionPersonalizationService } from "../suggestions/SuggestionPersonalizationService"; +import type { PredictionResponse, SuggestionManagerOptions } from "../suggestions/types"; +import { + DOCS_SESSION_ID, + KEY_EVENT, + KEY_STATE_ATTR, + KEY_ACK_ATTR, + parseObject, + planCompletion, + planGrammar, + sameSnapshot, + snapshotContext, + type DocsSnapshot, + type DocsReply, + type DocsEdit, +} from "./GoogleDocsModel"; +import { getDocsInput, type DocsInput } from "./GoogleDocsEnvironment"; +import { GoogleDocsBridgeClient, type DocsBridge } from "./GoogleDocsBridgeClient"; +import { GoogleDocsView, type DocsView } from "./GoogleDocsView"; + +interface Acceptance { + triggerText: string; + insertedText: string; + language: string; + suggestion: string; +} +interface TrackedEdit { + operationId?: string; + acceptance: Acceptance | null; + before: DocsSnapshot; +} +interface HistoryEdit extends TrackedEdit { + operationId: string; + eventId: string; + active: boolean; +} + +/** Async canvas-editor adapter using the normal predictor, grammar rules, theme and local services. */ +export class GoogleDocsAdapter { + private readonly prediction: SuggestionPredictionCoordinator; + private readonly grammar: SuggestionGrammarCoordinator; + private readonly state: PredictionSessionState = { + id: DOCS_SESSION_ID, + requestId: 0, + latestMentionText: "", + latestMentionStart: 0, + pendingRequestTimer: null, + }; + private readonly bridge: DocsBridge; + private readonly view: DocsView; + private readonly telemetry; + private readonly personalization; + private snapshot: DocsSnapshot | null = null; + private requested: { id: number; snapshot: DocsSnapshot } | null = null; + private suggestions: string[] = []; + private selectedIndex = 0; + private input: DocsInput | null = null; + private epoch = 0; + private composing = false; + private disposed = false; + private reading = false; + private applying = false; + private uncertain: TrackedEdit | null = null; + private lastEdit: HistoryEdit | null = null; + private grammarSuppressed: DocsSnapshot | null = null; + private visible = false; + private failureStatus: string | null = null; + private pollTimer: ReturnType | null = null; + private refreshTimer: ReturnType | null = null; + private idleTimer: ReturnType | null = null; + private readonly keyListener = (event: Event) => this.onKey(event as KeyboardEvent); + private readonly inputListener = (event: Event) => this.onInput(event as InputEvent); + private readonly compositionStart = () => { + this.composing = true; + this.dismiss(); + }; + private readonly compositionEnd = () => { + this.composing = false; + this.scheduleRefresh("insert", [], 60); + }; + private readonly navigationListener = (event: Event) => { + const owned = event + .composedPath() + .some( + (node) => + node instanceof Element && + (node.id === `ft-menu-${DOCS_SESSION_ID}` || + node.hasAttribute("data-ft-suggestion-owned")), + ); + if (!owned && !this.applying) this.dismiss(); + }; + private readonly layoutListener = () => this.render(); + private readonly bridgeKeyListener = (event: Event) => { + const value = parseObject((event as CustomEvent).detail); + if ( + !value || + typeof value.id !== "string" || + value.token !== this.snapshot?.token || + typeof value.key !== "string" + ) + return; + const input = getDocsInput(); + if (!input || !this.handleKey(value.key)) return; + input.frame.setAttribute(KEY_ACK_ATTR, value.id); + }; + + constructor( + private readonly options: SuggestionManagerOptions, + dependencies: { bridge?: DocsBridge; view?: DocsView } = {}, + ) { + this.bridge = dependencies.bridge ?? new GoogleDocsBridgeClient(); + this.telemetry = options.telemetry ?? new SuggestionTelemetryService(); + this.personalization = options.personalization ?? new SuggestionPersonalizationService(); + this.prediction = new SuggestionPredictionCoordinator({ + debounceByAction: { insert: 20, delete: 12, other: 20 }, + lang: options.lang, + minWordLengthToPredict: options.minWordLengthToPredict, + separatorRegex: LANG_SEPARATOR_CHARS_REGEX[options.lang] ?? /\s+/, + getPrediction: (context) => { + if (!this.snapshot || this.disposed || this.applying || this.composing) return; + this.requested = { id: context.requestId, snapshot: this.snapshot }; + options.getPrediction(context); + }, + }); + this.grammar = new SuggestionGrammarCoordinator({ + enabledGrammarRules: options.enabledGrammarRules, + insertSpaceAfterAutocomplete: options.insertSpaceAfterAutocomplete, + lang: options.lang, + userDictionaryList: options.userDictionaryList, + }); + this.view = + dependencies.view ?? + new GoogleDocsView({ + inline: options.inline_suggestion, + digits: options.selectByDigit, + langHeader: options.displayLangHeader, + findToken: (text) => this.prediction.findMentionToken(text), + accept: (index) => { + this.accept(index); + }, + }); + } + + start(): void { + if (this.disposed || this.pollTimer !== null) return; + document.addEventListener(KEY_EVENT, this.bridgeKeyListener); + document.addEventListener("pointerdown", this.navigationListener, true); + window.addEventListener("scroll", this.layoutListener, true); + window.addEventListener("resize", this.layoutListener); + document.addEventListener("visibilitychange", this.navigationListener); + this.pollTimer = setInterval(() => { + void this.refresh(); + }, 200); + void this.refresh(); + } + dispose(): void { + if (this.disposed) return; + this.dismiss(); + this.disposed = true; + if (this.pollTimer !== null) clearInterval(this.pollTimer); + this.pollTimer = null; + this.bind(null); + this.bridge.dispose(); + this.view.dispose(); + document.removeEventListener(KEY_EVENT, this.bridgeKeyListener); + document.removeEventListener("pointerdown", this.navigationListener, true); + window.removeEventListener("scroll", this.layoutListener, true); + window.removeEventListener("resize", this.layoutListener); + document.removeEventListener("visibilitychange", this.navigationListener); + } + updateLanguage(lang: string): void { + this.options.lang = lang; + this.dismiss(); + this.prediction.updateLang(lang, LANG_SEPARATOR_CHARS_REGEX[lang] ?? /\s+/); + this.grammar.updateLanguage(lang); + void this.refresh(true); + } + triggerActiveSuggestion(): void { + void this.refresh(true); + } + fulfillPrediction(response: PredictionResponse): void { + void this.receivePrediction(response); + } + + private async receivePrediction(response: PredictionResponse): Promise { + const request = this.requested; + if ( + !request || + response.suggestionId !== DOCS_SESSION_ID || + request.id !== response.requestId || + this.state.requestId !== response.requestId || + this.disposed || + this.applying || + this.composing + ) + return; + const epoch = this.epoch; + const reply = await this.bridge.read(); + if ( + epoch !== this.epoch || + this.requested !== request || + this.disposed || + this.applying || + !reply.snapshot || + !sameSnapshot(request.snapshot, reply.snapshot) || + !getDocsInput() + ) + return; + this.snapshot = reply.snapshot; + this.suggestions = (Array.isArray(response.predictions) ? response.predictions : []) + .filter((text): text is string => typeof text === "string" && this.completion(text) !== null) + .slice(0, 10); + this.selectedIndex = 0; + this.render(response.lang); + if (this.visible) + this.telemetry.recordSuggestionShown({ + suggestionCount: this.suggestions.length, + language: response.lang, + }); + } + + private async refresh( + force = false, + action?: PredictionInputAction, + triggers: GrammarEventType[] = [], + ): Promise { + if (this.disposed || this.applying || this.composing || this.reading || document.hidden) return; + const input = getDocsInput(); + if (!input) { + this.bind(null); + this.dismiss(); + return; + } + this.bind(input); + const epoch = this.epoch; + this.reading = true; + let reply: DocsReply; + try { + reply = await this.bridge.read(); + } catch { + reply = { status: "unavailable" }; + } finally { + this.reading = false; + } + if (this.disposed || epoch !== this.epoch || this.applying) return; + if (reply.status !== "ready" || !reply.snapshot) { + if (this.failureStatus !== reply.status) { + this.invalidatePrediction(); + this.snapshot = null; + this.clearVisual(); + this.view.status(reply.status); + } + this.failureStatus = reply.status; + return; + } + this.failureStatus = null; + this.observeHistory(reply); + // A ready host has acknowledged or dropped its journal; a dropped edit is not learned. + this.uncertain = null; + const snapshot = reply.snapshot; + const changed = !this.snapshot || !sameSnapshot(this.snapshot, snapshot); + if (this.hasNativePopup()) { + this.invalidatePrediction(); + this.clearVisual(); + return; + } + if (!changed && !force && !triggers.length) { + // Reads rotate the bounded single-use-token cache even while the text is unchanged. + // Renew the visible edit capability without re-requesting or re-announcing suggestions. + this.snapshot = snapshot; + this.updateKeyState(); + return; + } + if (changed) { + this.invalidatePrediction(); + this.clearVisual(); + } + this.snapshot = snapshot; + if (this.hasNativePopup()) { + this.clearVisual(); + return; + } + if (this.grammarSuppressed && !sameSnapshot(this.grammarSuppressed, snapshot)) + this.grammarSuppressed = null; + const context = snapshotContext(snapshot); + if ( + triggers.length && + !this.grammarSuppressed && + this.grammar.hasEnabledRules() && + snapshot.anchor === snapshot.focus + ) { + // Local grammar is paragraph-scoped; never capitalize from a truncated context window. + const paragraphStart = context.beforeCursor.lastIndexOf("\n") + 1; + if (snapshot.windowStart === 0 || paragraphStart > 0) { + const grammar = this.grammar.run({ + beforeCursor: context.beforeCursor.slice(paragraphStart), + afterCursor: context.afterCursor.split("\n")[0], + inputAction: action, + triggers, + }); + const edit = grammar && planGrammar(snapshot, grammar); + if (edit) { + void this.apply(edit, null); + return; + } + } + } + if (snapshot.anchor !== snapshot.focus && !force) { + this.clearVisual(); + return; + } + // Selected text is supplied as the explicit trigger; no autonomous selection replacement. + this.prediction.schedule(this.state, { + force, + inputAction: action, + beforeCursorOverride: context.beforeCursor + context.selectedText, + afterCursorOverride: context.afterCursor, + clearSuggestions: () => this.clearVisual(), + }); + } + + private completion(text: string): DocsEdit | null { + return ( + this.snapshot && + planCompletion( + this.snapshot, + text, + (value) => this.prediction.findMentionToken(value), + (char) => this.prediction.isSeparator(char), + this.options.insertSpaceAfterAutocomplete, + ) + ); + } + private accept(index: number): boolean { + if ( + !this.visible || + this.applying || + this.composing || + this.disposed || + this.uncertain || + !this.snapshot + ) + return false; + const suggestion = this.suggestions[index]; + const edit = suggestion && this.completion(suggestion); + if (!edit) return false; + const context = snapshotContext(this.snapshot); + const acceptance = { + triggerText: + context.selectedText || this.prediction.findMentionToken(context.beforeCursor).token, + insertedText: edit.replacement, + language: this.options.lang, + suggestion, + }; + void this.apply(edit, acceptance); + return true; + } + private async apply(edit: DocsEdit, acceptance: Acceptance | null): Promise { + const snapshot = this.snapshot; + if (!snapshot || this.applying || this.disposed || this.uncertain) return; + this.applying = true; + this.invalidatePrediction(); + this.clearVisual(); + const tracked: TrackedEdit = { acceptance, before: snapshot }; + let reply: DocsReply; + try { + reply = await this.bridge.apply(snapshot.token, edit); + } catch { + reply = { status: "unverified" }; + } + this.applying = false; + if (this.disposed) return; + tracked.operationId = reply.operationId; + if (reply.status === "applied" && reply.operationId) + this.recordApplied(tracked, reply.operationId); + else if (reply.status === "unverified") this.uncertain = tracked; + this.snapshot = null; + this.view.status(reply.status); + if (!this.uncertain) void this.refresh(); + } + private recordApplied(edit: TrackedEdit, operationId: string): void { + if (this.lastEdit?.operationId === operationId) return; + const eventId = edit.acceptance + ? this.personalization.recordSuggestionAccepted(edit.acceptance) + : ""; + if (edit.acceptance) this.telemetry.recordSuggestionAccepted(edit.acceptance); + this.lastEdit = { ...edit, operationId, eventId, active: true }; + } + private observeHistory(reply: DocsReply): void { + if (!reply.operationId || !reply.snapshot || !reply.history) return; + if ( + this.uncertain && + reply.history === "applied" && + (!this.uncertain.operationId || this.uncertain.operationId === reply.operationId) && + this.uncertain.before.scope === reply.snapshot.scope + ) { + this.recordApplied(this.uncertain, reply.operationId); + this.uncertain = null; + } + const last = this.lastEdit; + if (!last || last.operationId !== reply.operationId) return; + if (reply.history === "undone" && last.active) { + if (last.eventId) this.personalization.recordSuggestionReverted(last.eventId); + last.active = false; + this.grammarSuppressed = reply.snapshot; + } else if (reply.history === "applied" && !last.active) { + last.eventId = last.acceptance + ? this.personalization.recordSuggestionAccepted(last.acceptance) + : ""; + last.active = true; + } + } + private handleKey(key: string): boolean { + if (!this.visible || !this.snapshot || this.applying || this.composing || this.hasNativePopup()) + return false; + if (key === "Escape") { + this.dismiss(); + return true; + } + if (key === "ArrowDown" || key === "ArrowUp") { + this.selectedIndex = + (this.selectedIndex + (key === "ArrowDown" ? 1 : -1) + this.suggestions.length) % + this.suggestions.length; + this.render(); + return true; + } + if (this.options.selectByDigit && /^\d$/.test(key)) + return this.accept(key === "0" ? 9 : Number(key) - 1); + if ( + (key === "Tab" && (this.options.autocompleteOnTab || this.options.inline_suggestion)) || + (key === "Enter" && this.options.autocompleteOnEnter) || + (key === " " && this.options.autocomplete) + ) + return this.accept(this.selectedIndex); + return false; + } + private onKey(event: KeyboardEvent): void { + if (event.isComposing || event.keyCode === 229) { + this.composing = true; + this.dismiss(); + return; + } + if (event.defaultPrevented) return; + if ( + !event.repeat && + !event.ctrlKey && + !event.altKey && + !event.metaKey && + !event.shiftKey && + this.handleKey(event.key) + ) { + event.preventDefault(); + event.stopImmediatePropagation(); + return; + } + // Native undo/redo is deliberately not intercepted. History is checked against the model. + this.dismiss(); + } + private onInput(event: InputEvent): void { + if (this.applying || this.disposed) return; + this.dismiss(); + if (event.isComposing || this.composing) return; + const type = event.inputType || ""; + const action = type.startsWith("delete") + ? "delete" + : type.startsWith("insert") + ? "insert" + : "other"; + const triggers: GrammarEventType[] = action === "insert" ? ["insertChar"] : []; + if (type === "insertFromPaste") triggers.push("paste"); + if (event.data && this.prediction.isSeparator(event.data.slice(-1))) + triggers.push("wordBoundary"); + this.scheduleRefresh(action, triggers, 25); + if (action === "insert") + this.idleTimer = setTimeout(() => { + void this.refresh(false, action, ["idle"]); + }, 240); + } + private scheduleRefresh( + action: PredictionInputAction, + triggers: GrammarEventType[], + delay: number, + ): void { + if (this.refreshTimer !== null) clearTimeout(this.refreshTimer); + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + void this.refresh(false, action, triggers); + }, delay); + } + private render(language = this.options.lang): void { + if (!this.snapshot || !getDocsInput() || !this.suggestions.length || this.hasNativePopup()) { + this.clearVisual(); + return; + } + this.visible = this.view.render(this.suggestions, this.selectedIndex, this.snapshot, language); + this.updateKeyState(); + } + private updateKeyState(): void { + if (!this.snapshot) { + this.input?.frame.removeAttribute(KEY_STATE_ATTR); + return; + } + const keys = ["Escape", "ArrowUp", "ArrowDown"]; + if (this.options.autocompleteOnTab || this.options.inline_suggestion) keys.push("Tab"); + if (this.options.autocompleteOnEnter) keys.push("Enter"); + if (this.options.autocomplete) keys.push(" "); + if (this.options.selectByDigit) + keys.push(...this.suggestions.map((_, index) => (index === 9 ? "0" : String(index + 1)))); + if (this.visible) + this.input?.frame.setAttribute( + KEY_STATE_ATTR, + JSON.stringify({ token: this.snapshot.token, keys }), + ); + else this.input?.frame.removeAttribute(KEY_STATE_ATTR); + } + private hasNativePopup(): boolean { + if (!this.options.preferNativeAutocomplete) return false; + const input = getDocsInput(); + const controls = input?.element.getAttribute("aria-controls")?.split(/\s+/) ?? []; + return controls.some((id) => { + const popup = input?.document.getElementById(id) ?? document.getElementById(id); + return ( + !!popup && + !popup.closest('[data-ft-suggestion-owned="true"]') && + popup.getAttribute("aria-hidden") !== "true" && + popup.getClientRects().length > 0 + ); + }); + } + private invalidatePrediction(): void { + this.epoch += 1; + this.state.requestId += 1; + this.requested = null; + this.suggestions = []; + this.selectedIndex = 0; + this.prediction.cancelPending(this.state); + } + private clearVisual(): void { + this.visible = false; + this.view.clear(); + this.input?.frame.removeAttribute(KEY_STATE_ATTR); + } + private dismiss(): void { + this.invalidatePrediction(); + this.bridge.cancel(); + this.clearVisual(); + this.snapshot = null; + this.failureStatus = null; + this.suggestions = []; + if (this.refreshTimer !== null) clearTimeout(this.refreshTimer); + if (this.idleTimer !== null) clearTimeout(this.idleTimer); + this.refreshTimer = null; + this.idleTimer = null; + } + private bind(input: DocsInput | null): void { + if (input?.document === this.input?.document && input?.element === this.input?.element) return; + const old = this.input; + old?.frame.removeAttribute(KEY_STATE_ATTR); + old?.document.removeEventListener("keydown", this.keyListener, true); + old?.document.removeEventListener("input", this.inputListener, true); + old?.document.removeEventListener("compositionstart", this.compositionStart, true); + old?.document.removeEventListener("compositionend", this.compositionEnd, true); + old?.document.removeEventListener("pointerdown", this.navigationListener, true); + this.input = input; + this.composing = false; + input?.document.addEventListener("keydown", this.keyListener, true); + input?.document.addEventListener("input", this.inputListener, true); + input?.document.addEventListener("compositionstart", this.compositionStart, true); + input?.document.addEventListener("compositionend", this.compositionEnd, true); + input?.document.addEventListener("pointerdown", this.navigationListener, true); + } +} diff --git a/src/adapters/chrome/content-script/google-docs/GoogleDocsBridgeClient.ts b/src/adapters/chrome/content-script/google-docs/GoogleDocsBridgeClient.ts new file mode 100644 index 00000000..cb649b6f --- /dev/null +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsBridgeClient.ts @@ -0,0 +1,97 @@ +import { + REQUEST_EVENT, + RESPONSE_EVENT, + parseObject, + snapshotFrom, + type DocsReply, + type DocsEdit, + type DocsStatus, +} from "./GoogleDocsModel"; +const STATUSES = new Set([ + "ready", + "applied", + "stale", + "inactive", + "unavailable", + "invalid", + "busy", + "composing", + "cancelled", + "unverified", + "unsupported-selection", +]); +export interface DocsBridge { + read(): Promise; + apply(token: string, edit: DocsEdit): Promise; + cancel(): void; + dispose(): void; +} +export class GoogleDocsBridgeClient implements DocsBridge { + private readonly pending = new Map< + string, + { resolve: (value: DocsReply) => void; timer: number } + >(); + private disposed = false; + private readonly listener = (event: Event) => { + const value = parseObject((event as CustomEvent).detail); + if (!value || typeof value.id !== "string" || !STATUSES.has(value.status as DocsStatus)) return; + const request = this.pending.get(value.id); + if (!request) return; + const snapshot = snapshotFrom(value.snapshot); + if (value.status === "ready" && !snapshot) return; + this.win.clearTimeout(request.timer); + this.pending.delete(value.id); + request.resolve({ + status: value.status as DocsStatus, + ...(snapshot ? { snapshot } : {}), + ...(typeof value.operationId === "string" && value.operationId.length <= 100 + ? { operationId: value.operationId } + : {}), + ...(value.history === "applied" || value.history === "undone" + ? { history: value.history } + : {}), + }); + }; + constructor(private readonly win: Window = window) { + win.document.addEventListener(RESPONSE_EVENT, this.listener); + } + read(): Promise { + return this.request("read"); + } + apply(token: string, edit: DocsEdit): Promise { + return this.request("apply", { token, edit }); + } + cancel(): void { + if (!this.disposed) + this.win.document.dispatchEvent( + new CustomEvent(REQUEST_EVENT, { + detail: JSON.stringify({ kind: "cancel", id: this.win.crypto.randomUUID() }), + }), + ); + } + dispose(): void { + this.cancel(); + this.disposed = true; + this.win.document.removeEventListener(RESPONSE_EVENT, this.listener); + for (const value of this.pending.values()) { + this.win.clearTimeout(value.timer); + value.resolve({ status: "cancelled" }); + } + this.pending.clear(); + } + private request(kind: "read" | "apply", payload: object = {}): Promise { + if (this.disposed) return Promise.resolve({ status: "cancelled" }); + if (this.pending.size >= 2) return Promise.resolve({ status: "busy" }); + return new Promise((resolve) => { + const id = this.win.crypto.randomUUID(); + const timer = this.win.setTimeout(() => { + this.pending.delete(id); + resolve({ status: kind === "apply" ? "unverified" : "unavailable" }); + }, 3500); + this.pending.set(id, { resolve, timer }); + this.win.document.dispatchEvent( + new CustomEvent(REQUEST_EVENT, { detail: JSON.stringify({ id, kind, ...payload }) }), + ); + }); + } +} diff --git a/src/adapters/chrome/content-script/google-docs/GoogleDocsEnvironment.ts b/src/adapters/chrome/content-script/google-docs/GoogleDocsEnvironment.ts new file mode 100644 index 00000000..3bda9c3b --- /dev/null +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsEnvironment.ts @@ -0,0 +1,81 @@ +import { INPUT_FRAME_SELECTOR, isGoogleDocsURL } from "./GoogleDocsModel"; + +export function isGoogleDocsPage(win: Window = window): boolean { + return win.top === win && isGoogleDocsURL(win.location.href); +} +export function isGoogleDocsInputFrame(win: Window = window): boolean { + try { + return ( + win.top !== win && + !!win.top && + isGoogleDocsPage(win.top) && + !!win.frameElement?.matches(INPUT_FRAME_SELECTOR) + ); + } catch { + return false; + } +} +export interface DocsInput { + frame: HTMLIFrameElement; + document: Document; + element: HTMLElement; +} +export function getDocsInput(doc: Document = document): DocsInput | null { + const frame = doc.activeElement; + if (!frame?.matches(INPUT_FRAME_SELECTOR)) return null; + try { + const iframe = frame as HTMLIFrameElement; + const inner = iframe.contentDocument; + // Docs' setSelection blurs the inner editable while the frame stays focused; fall back + // to the frame's editable target instead of treating that transient blur as inactive. + const active = inner?.activeElement as HTMLElement | null; + const element = active?.isContentEditable + ? active + : inner?.querySelector('[contenteditable="true"]'); + if (!inner || !element || element.getAttribute("aria-readonly") === "true") return null; + return { frame: iframe, document: inner, element }; + } catch { + return null; + } +} + +/** Read visual geometry only; never move the selection to measure. */ +export function getDocsCaret( + doc: Document = document, +): { element: HTMLElement; rect: DOMRect } | null { + const view = doc.defaultView; + if (!view) return null; + const visible = Array.from(doc.querySelectorAll(".kix-cursor-caret")) + .filter( + (el) => + !el.closest('[aria-hidden="true"]') && view.getComputedStyle(el).visibility !== "hidden", + ) + .map((element) => ({ element, rect: element.getBoundingClientRect() })) + .filter( + ({ rect }) => + rect.height > 0 && + rect.width >= 0 && + rect.bottom > 0 && + rect.top < view.innerHeight && + rect.right >= 0 && + rect.left < view.innerWidth, + ); + // Collaborator carets are colored; Docs draws the local caret with a black border. + const own = visible.filter( + ({ element }) => view.getComputedStyle(element).borderLeftColor === "rgb(0, 0, 0)", + ); + const candidates = own.length ? own : visible; + // Bidi can show coincident caret fragments. Deduplicate geometry. + const unique = candidates.filter( + (item, index) => + !candidates + .slice(0, index) + .some( + (other) => + Math.abs(item.rect.left - other.rect.left) < 0.5 && + Math.abs(item.rect.top - other.rect.top) < 0.5 && + Math.abs(item.rect.height - other.rect.height) < 0.5, + ), + ); + return unique.length === 1 ? unique[0] : null; +} diff --git a/src/adapters/chrome/content-script/google-docs/GoogleDocsMainWorld.ts b/src/adapters/chrome/content-script/google-docs/GoogleDocsMainWorld.ts new file mode 100644 index 00000000..2dd3fbf7 --- /dev/null +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsMainWorld.ts @@ -0,0 +1,260 @@ +import { + REQUEST_EVENT, + RESPONSE_EVENT, + KEY_EVENT, + KEY_STATE_ATTR, + KEY_ACK_ATTR, + INPUT_FRAME_SELECTOR, + parseObject, + readModel, + type DocsEdit, +} from "./GoogleDocsModel"; +import { getDocsInput, isGoogleDocsPage, isGoogleDocsInputFrame } from "./GoogleDocsEnvironment"; +import { DocsHostError, GoogleDocsTransaction, type DocsHostState } from "./GoogleDocsTransaction"; + +interface AnnotatedText { + getText(): unknown; + getSelection(): unknown; + setSelection(anchor: number, focus: number): void; +} +type DocsWindow = Window & { + _docs_annotate_canvas_by_ext?: unknown; + _docs_annotate_getAnnotatedText?: () => AnnotatedText | Promise; +}; +const COMPOSING_ATTR = "data-ft-docs-composing"; + +/** Page-visible messages are not an authentication boundary and grant no extension APIs. */ +export function installGoogleDocsMainWorld(win: Window = window): () => void { + if (win.top !== win) return installFrameKeys(win); + if (!isGoogleDocsPage(win)) return () => {}; + const docs = win as DocsWindow; + try { + // Use FluentTyper's own ID, never impersonate another extension. + if (docs._docs_annotate_canvas_by_ext == null) { + docs._docs_annotate_canvas_by_ext = /Edg\//.test(win.navigator.userAgent) + ? "ljenfpihmhkddgmjoipinkhflinoofcn" + : "mbjlobpodpimgbkmlmjiblnmfgajmebm"; + } + } catch { + /* Missing capability is reported on read. */ + } + let stopped = false; + let interaction = 0; + let inner: Document | null = null; + let composing = false; + let compositionSettlesAt = 0; + const states = new WeakMap(); + const onInteraction = (event: Event) => { + if (event.isTrusted) interaction += 1; + }; + const onCompositionStart = () => { + composing = true; + interaction += 1; + transactions.cancel(); + }; + const onCompositionEnd = () => { + composing = false; + compositionSettlesAt = Date.now() + 50; + interaction += 1; + }; + const bind = (doc: Document | null) => { + for (const kind of ["keydown", "pointerdown", "beforeinput"]) + inner?.removeEventListener(kind, onInteraction, true); + inner?.removeEventListener("compositionstart", onCompositionStart, true); + inner?.removeEventListener("compositionend", onCompositionEnd, true); + inner = doc; + for (const kind of ["keydown", "pointerdown", "beforeinput"]) + inner?.addEventListener(kind, onInteraction, true); + inner?.addEventListener("compositionstart", onCompositionStart, true); + inner?.addEventListener("compositionend", onCompositionEnd, true); + }; + const transactions = new GoogleDocsTransaction( + { + read: async () => { + if (stopped || !isGoogleDocsPage(win)) throw new DocsHostError("inactive"); + const input = getDocsInput(win.document); + if (!input) throw new DocsHostError("inactive"); + if (inner !== input.document) bind(input.document); + if ( + composing || + Date.now() < compositionSettlesAt || + input.frame.hasAttribute(COMPOSING_ATTR) + ) { + throw new DocsHostError("composing"); + } + if (typeof docs._docs_annotate_getAnnotatedText !== "function") + throw new DocsHostError("unavailable"); + const scope = win.location.href; + const version = interaction; + const annotated = await docs._docs_annotate_getAnnotatedText(); + if ( + !annotated || + typeof annotated.getText !== "function" || + typeof annotated.getSelection !== "function" || + typeof annotated.setSelection !== "function" + ) { + throw new DocsHostError("unavailable"); + } + const current = getDocsInput(win.document); + if ( + stopped || + current?.element !== input.element || + current.document !== input.document || + scope !== win.location.href || + version !== interaction || + composing + ) + throw new DocsHostError("stale"); + const model = readModel(annotated.getText(), annotated.getSelection()); + if (!model) throw new DocsHostError("unsupported-selection"); + const state = { model, scope, input: input.element, interaction }; + states.set(state, annotated); + return state; + }, + select: (state, anchor, focus) => { + const api = states.get(state); + if (!api || getDocsInput(win.document)?.element !== state.input) + throw new DocsHostError("stale"); + api.setSelection(anchor + state.model.offset, focus + state.model.offset); + // setSelection blurs the editable target; paste must reach a focused editor. + (state.input as HTMLElement).focus(); + }, + paste: (state, text) => { + const input = getDocsInput(win.document); + if ( + !input || + input.element !== state.input || + state.scope !== win.location.href || + composing + ) { + throw new DocsHostError("stale"); + } + const realm = input.document.defaultView; + if (!realm) throw new DocsHostError("inactive"); + const data = new realm.DataTransfer(); + // Docs strips leading/trailing ASCII spaces from pasted text but converts NBSP to a + // regular space, so edge spaces travel as NBSP and the model still shows " ". + data.setData( + "text/plain", + text.replace(/^ +| +$/g, (run) => "\u00a0".repeat(run.length)), + ); + // A request to the editor, NOT trusted/native paste. Its return value is irrelevant. + input.element.dispatchEvent( + new realm.ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData: data, + }), + ); + }, + }, + () => win.crypto.randomUUID(), + ); + let pending = 0; + const onRequest = (event: Event) => { + if (stopped || !isGoogleDocsPage(win)) return; + const request = parseObject((event as CustomEvent).detail); + if (!request || typeof request.id !== "string" || !/^[\w-]{1,100}$/.test(request.id)) return; + if (request.kind === "cancel") { + transactions.cancel(); + return; + } + if (request.kind !== "read" && request.kind !== "apply") return; + const reply = (value: object) => { + if (!stopped) + win.document.dispatchEvent( + new CustomEvent(RESPONSE_EVENT, { + detail: JSON.stringify({ id: request.id, ...value }), + }), + ); + }; + if (pending >= 2) { + reply({ status: "busy" }); + return; + } + if ( + request.kind === "apply" && + (typeof request.token !== "string" || !request.edit || typeof request.edit !== "object") + ) { + reply({ status: "invalid" }); + return; + } + pending += 1; + void ( + request.kind === "read" + ? transactions.read() + : transactions.apply(request.token as string, request.edit as DocsEdit) + ) + .then(reply, () => reply({ status: "unverified" })) + .finally(() => { + pending -= 1; + }); + }; + win.document.addEventListener(REQUEST_EVENT, onRequest); + return () => { + stopped = true; + transactions.cancel(); + bind(null); + win.document.removeEventListener(REQUEST_EVENT, onRequest); + }; +} + +function installFrameKeys(win: Window): () => void { + try { + if (!win.top || !isGoogleDocsPage(win.top)) return () => {}; + } catch { + return () => {}; + } + const composing = () => { + win.frameElement?.setAttribute(COMPOSING_ATTR, "true"); + }; + const composed = () => { + win.frameElement?.removeAttribute(COMPOSING_ATTR); + }; + const keydown = (event: KeyboardEvent) => { + if ( + !event.isTrusted || + event.isComposing || + event.keyCode === 229 || + event.defaultPrevented || + event.repeat || + event.ctrlKey || + event.altKey || + event.metaKey || + event.shiftKey || + !isGoogleDocsInputFrame(win) + ) + return; + const frame = win.frameElement; + const state = parseObject(frame?.getAttribute(KEY_STATE_ATTR)); + if ( + !frame || + !state || + typeof state.token !== "string" || + !Array.isArray(state.keys) || + !state.keys.includes(event.key) + ) + return; + const id = win.crypto.randomUUID(); + frame.removeAttribute(KEY_ACK_ATTR); + win.top?.document.dispatchEvent( + new CustomEvent(KEY_EVENT, { + detail: JSON.stringify({ id, token: state.token, key: event.key }), + }), + ); + if (frame.getAttribute(KEY_ACK_ATTR) === id) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + frame.removeAttribute(KEY_ACK_ATTR); + }; + win.addEventListener("keydown", keydown, true); + win.addEventListener("compositionstart", composing, true); + win.addEventListener("compositionend", composed, true); + return () => { + win.removeEventListener("keydown", keydown, true); + win.removeEventListener("compositionstart", composing, true); + win.removeEventListener("compositionend", composed, true); + if (win.frameElement?.matches(INPUT_FRAME_SELECTOR)) composed(); + }; +} diff --git a/src/adapters/chrome/content-script/google-docs/GoogleDocsModel.ts b/src/adapters/chrome/content-script/google-docs/GoogleDocsModel.ts new file mode 100644 index 00000000..4f78aac9 --- /dev/null +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsModel.ts @@ -0,0 +1,341 @@ +import type { GrammarEdit } from "@core/domain/grammar/types"; + +export const DOCS_SESSION_ID = -1; +export const MAX_CONTEXT = 8192; +export const MAX_EDIT = 16384; +export const MAX_DOCUMENT = 2_000_000; +export const SNAPSHOT_LIFETIME_MS = 10000; +export const REQUEST_EVENT = "fluenttyper:gdocs:v2:request"; +export const RESPONSE_EVENT = "fluenttyper:gdocs:v2:response"; +export const KEY_EVENT = "fluenttyper:gdocs:v2:key"; +export const KEY_STATE_ATTR = "data-ft-docs-key-state"; +export const KEY_ACK_ATTR = "data-ft-docs-key-ack"; +export const INPUT_FRAME_SELECTOR = "iframe.docs-texteventtarget-iframe"; + +export interface DocsModel { + raw: string; + text: string; + offset: number; + anchor: number; + focus: number; +} +export interface DocsSnapshot { + token: string; + scope: string; + text: string; + windowStart: number; + documentLength: number; + anchor: number; + focus: number; +} +export interface DocsEdit { + start: number; + end: number; + replacement: string; + cursorAfter: number; +} +export type DocsStatus = + | "ready" + | "applied" + | "stale" + | "inactive" + | "unavailable" + | "invalid" + | "busy" + | "composing" + | "cancelled" + | "unverified" + | "unsupported-selection"; +export interface DocsReply { + status: DocsStatus; + snapshot?: DocsSnapshot; + operationId?: string; + history?: "applied" | "undone"; +} + +/** Only top-level document edit URLs; per-site enable/disable still applies. */ +export function isGoogleDocsURL(href: string): boolean { + try { + const url = new URL(href); + return ( + url.origin === "https://docs.google.com" && + /^\/document\/(?:u\/\d+\/)?d\/[\w-]+\/edit\/?$/.test(url.pathname) + ); + } catch { + return false; + } +} + +export function parseObject(value: unknown): Record | null { + if (typeof value !== "string" || value.length > 200000) return null; + try { + const parsed: unknown = JSON.parse(value); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +export function isBoundary(text: string, index: number): boolean { + if (!Number.isSafeInteger(index) || index < 0 || index > text.length) return false; + if (index === 0 || index === text.length) return true; + // Segment the local string, not a multi-megabyte document per boundary query. + // Callers use the bounded context/range for editing, and full text only for selections. + const segments = new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text); + return segments.containing(index)?.index === index; +} + +export function readModel(raw: unknown, selection: unknown): DocsModel | null { + if ( + typeof raw !== "string" || + raw.length > MAX_DOCUMENT || + !Array.isArray(selection) || + selection.length !== 1 || + !selection[0] || + typeof selection[0] !== "object" + ) + return null; + const value = selection[0] as Record; + let endpoints: { anchor: number; focus: number } | null = null; + for (const [a, b] of [ + ["anchor", "focus"], + ["base", "extent"], + ["start", "end"], + ]) { + if (!(a in value) && !(b in value)) continue; + const anchor = value[a], + focus = value[b]; + if ( + typeof anchor !== "number" || + typeof focus !== "number" || + !Number.isSafeInteger(anchor) || + !Number.isSafeInteger(focus) + ) + return null; + if (endpoints && (endpoints.anchor !== anchor || endpoints.focus !== focus)) return null; + endpoints = { anchor, focus }; + } + if (!endpoints) return null; + const offset = raw.startsWith("\u0003") ? 1 : 0; + const end = raw.length - (raw.endsWith("\n") ? 1 : 0); + if ( + end < offset || + endpoints.anchor < offset || + endpoints.focus < offset || + endpoints.anchor > end || + endpoints.focus > end + ) + return null; + const text = raw.slice(offset, end); + const anchor = endpoints.anchor - offset, + focus = endpoints.focus - offset; + if (!isBoundary(text, anchor) || !isBoundary(text, focus)) return null; + return { raw, text, offset, anchor, focus }; +} + +export function sameModel(a: DocsModel, b: DocsModel): boolean { + return a.raw === b.raw && a.anchor === b.anchor && a.focus === b.focus; +} + +export function snapshotFor(model: DocsModel, scope: string, token: string): DocsSnapshot | null { + const start = Math.min(model.anchor, model.focus), + end = Math.max(model.anchor, model.focus); + if (end - start > MAX_EDIT) return null; + let windowStart = Math.max(0, start - MAX_CONTEXT); + let windowEnd = Math.min(model.text.length, end + MAX_CONTEXT); + while (!isBoundary(model.text, windowStart)) windowStart += 1; + while (!isBoundary(model.text, windowEnd)) windowEnd -= 1; + return { + token, + scope, + text: model.text.slice(windowStart, windowEnd), + windowStart, + documentLength: model.text.length, + anchor: model.anchor, + focus: model.focus, + }; +} + +export function snapshotContext(snapshot: DocsSnapshot) { + const start = Math.min(snapshot.anchor, snapshot.focus) - snapshot.windowStart; + const end = Math.max(snapshot.anchor, snapshot.focus) - snapshot.windowStart; + return { + beforeCursor: snapshot.text.slice(0, start), + afterCursor: snapshot.text.slice(end), + selectedText: snapshot.text.slice(start, end), + start, + end, + }; +} + +export function sameSnapshot(a: DocsSnapshot, b: DocsSnapshot): boolean { + return ( + a.scope === b.scope && + a.text === b.text && + a.windowStart === b.windowStart && + a.documentLength === b.documentLength && + a.anchor === b.anchor && + a.focus === b.focus + ); +} + +export function validEdit(text: string, edit: DocsEdit): boolean { + const { start, end, replacement, cursorAfter } = edit; + if ( + typeof replacement !== "string" || + replacement.length > MAX_EDIT || + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + end < start || + end - start > MAX_EDIT || + !isBoundary(text, start) || + !isBoundary(text, end) + ) + return false; + // Docs' structural markers are not ordinary text. Never replace across them. + // eslint-disable-next-line no-control-regex -- Reject private editor structural controls. + const protectedControls = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffc]/u; + if (protectedControls.test(replacement) || protectedControls.test(text.slice(start, end))) + return false; + const result = text.slice(0, start) + replacement + text.slice(end); + return ( + isBoundary(result, cursorAfter) && + cursorAfter >= start && + cursorAfter <= start + replacement.length + ); +} + +/** Preserve unchanged prefix/suffix runs instead of rewriting a whole styled token. */ +export function minimizeEdit(text: string, edit: DocsEdit): DocsEdit { + const original = text.slice(edit.start, edit.end); + let prefix = 0; + while ( + prefix < original.length && + prefix < edit.replacement.length && + original[prefix] === edit.replacement[prefix] + ) + prefix += 1; + while (prefix > 0 && (!isBoundary(original, prefix) || !isBoundary(edit.replacement, prefix))) + prefix -= 1; + let suffix = 0; + while ( + suffix < original.length - prefix && + suffix < edit.replacement.length - prefix && + original[original.length - suffix - 1] === + edit.replacement[edit.replacement.length - suffix - 1] + ) + suffix += 1; + while ( + suffix > 0 && + (!isBoundary(original, original.length - suffix) || + !isBoundary(edit.replacement, edit.replacement.length - suffix)) + ) + suffix -= 1; + // The final caret can be outside the minimal changed range (e.g. spelling in mid-word). + return { + start: edit.start + prefix, + end: edit.end - suffix, + replacement: edit.replacement.slice(prefix, edit.replacement.length - suffix), + cursorAfter: edit.cursorAfter, + }; +} + +export function planCompletion( + snapshot: DocsSnapshot, + suggestion: string, + findToken: (text: string) => { token: string; start: number }, + isSeparator: (char: string) => boolean, + skipFollowingSpace = false, +): DocsEdit | null { + const context = snapshotContext(snapshot); + const token = findToken(context.beforeCursor); + if (!context.selectedText && token.token && token.start === 0 && snapshot.windowStart > 0) + return null; + let start = context.selectedText ? context.start : token.start; + let end = context.end; + if (!context.selectedText) { + // A next-word proposal inserts at the caret, never deletes the following word. + if (token.token) { + while (end < snapshot.text.length && !isSeparator(snapshot.text[end])) end += 1; + } + if (/[ \xa0]$/.test(suggestion) && /[ \xa0]/.test(snapshot.text[end] ?? "")) end += 1; + // No space was appended because one already follows: land the caret after it. + else if (skipFollowingSpace && /[ \xa0]/.test(snapshot.text[end] ?? "")) { + suggestion += snapshot.text[end]; + end += 1; + } + } + if ( + !context.selectedText && + token.token && + end === snapshot.text.length && + snapshot.windowStart + end < snapshot.documentLength + ) + return null; + start = Math.max(0, start); + const local = { start, end, replacement: suggestion, cursorAfter: start + suggestion.length }; + if (!suggestion || !validEdit(snapshot.text, local)) return null; + return { + ...local, + start: start + snapshot.windowStart, + end: end + snapshot.windowStart, + cursorAfter: local.cursorAfter + snapshot.windowStart, + }; +} + +export function planGrammar(snapshot: DocsSnapshot, edit: GrammarEdit): DocsEdit | null { + if (snapshot.anchor !== snapshot.focus) return null; + const cursor = snapshot.anchor - snapshot.windowStart; + const start = cursor - edit.deleteBackwards, + end = cursor + edit.deleteForwards; + const local = { + start, + end, + replacement: edit.replacement, + cursorAfter: start + (edit.cursorOffset ?? edit.replacement.length), + }; + if (!validEdit(snapshot.text, local)) return null; + return { + ...local, + start: start + snapshot.windowStart, + end: end + snapshot.windowStart, + cursorAfter: local.cursorAfter + snapshot.windowStart, + }; +} + +export function snapshotFrom(value: unknown): DocsSnapshot | null { + if (!value || typeof value !== "object") return null; + const s = value as Record; + if ( + typeof s.token !== "string" || + s.token.length > 100 || + typeof s.scope !== "string" || + s.scope.length > 4096 || + typeof s.text !== "string" || + s.text.length > MAX_CONTEXT * 2 + MAX_EDIT || + typeof s.documentLength !== "number" || + !Number.isSafeInteger(s.documentLength) || + s.documentLength < 0 || + s.documentLength > MAX_DOCUMENT || + typeof s.windowStart !== "number" || + !Number.isSafeInteger(s.windowStart) || + s.windowStart < 0 || + s.windowStart + s.text.length > s.documentLength || + typeof s.anchor !== "number" || + typeof s.focus !== "number" || + !isBoundary(s.text, s.anchor - s.windowStart) || + !isBoundary(s.text, s.focus - s.windowStart) + ) + return null; + return { + token: s.token, + scope: s.scope, + text: s.text, + windowStart: s.windowStart, + documentLength: s.documentLength, + anchor: s.anchor, + focus: s.focus, + }; +} diff --git a/src/adapters/chrome/content-script/google-docs/GoogleDocsTransaction.ts b/src/adapters/chrome/content-script/google-docs/GoogleDocsTransaction.ts new file mode 100644 index 00000000..a1ad6438 --- /dev/null +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsTransaction.ts @@ -0,0 +1,232 @@ +import { + SNAPSHOT_LIFETIME_MS, + minimizeEdit, + sameModel, + snapshotFor, + validEdit, + type DocsEdit, + type DocsModel, + type DocsReply, + type DocsSnapshot, + type DocsStatus, +} from "./GoogleDocsModel"; + +export interface DocsHostState { + model: DocsModel; + scope: string; + /** Object identity, not a selector: replacements/reloads invalidate a pending edit. */ + input: object; + interaction: number; +} +export interface DocsHost { + read(): Promise; + select(state: DocsHostState, anchor: number, focus: number): void; + paste(state: DocsHostState, text: string): void; +} +export class DocsHostError extends Error { + constructor(public readonly status: DocsStatus) { + super(status); + } +} +interface Cached { + state: DocsHostState; + at: number; +} +interface Journal { + id: string; + before: DocsHostState; + expectedRaw: string; + edit: DocsEdit; + uncertain: boolean; +} + +/** + * Model-verified, single-flight edits. This is NOT a compare-and-swap API: Docs exposes + * no collaborative revision transaction. Never retry a dispatched paste or undo a + * mismatched document to manufacture a successful result. + */ +export class GoogleDocsTransaction { + private readonly tokens = new Map(); + private epoch = 0; + private busy = false; + private journal: Journal | null = null; + constructor( + private readonly host: DocsHost, + private readonly createId: () => string = () => crypto.randomUUID(), + private readonly now: () => number = () => Date.now(), + ) {} + + cancel(): void { + this.epoch += 1; + this.tokens.clear(); + } + + async read(): Promise { + if (this.busy) return { status: "busy" }; + const epoch = this.epoch; + try { + const state = await this.readHost(); + if (epoch !== this.epoch) return { status: "cancelled" }; + if (this.journal?.uncertain) { + if (this.isExpected(state, this.journal)) { + // Late acknowledgement: reopen only after the exact expected model is observed. + this.journal.uncertain = false; + } else if (state.interaction === this.journal.before.interaction) { + return { status: "unverified", operationId: this.journal.id }; + } else { + // The user acted after the unverified write; stop blocking, never retry it. + this.journal = null; + } + } + const snapshot = this.cache(state); + if (!snapshot) return { status: "unsupported-selection" }; + const journal = this.journal; + const history = + journal && state.scope === journal.before.scope && state.input === journal.before.input + ? state.model.raw === journal.expectedRaw + ? "applied" + : state.model.raw === journal.before.model.raw + ? "undone" + : undefined + : undefined; + return { + status: "ready", + snapshot, + ...(history && journal ? { operationId: journal.id, history } : {}), + }; + } catch (error) { + return this.failure(error); + } + } + + async apply(token: string, edit: DocsEdit): Promise { + if (this.busy) return { status: "busy" }; + if (this.journal?.uncertain) return { status: "unverified", operationId: this.journal.id }; + const cached = this.tokens.get(token); + this.tokens.delete(token); // Single use, including validation failure and concurrent replays. + if (!cached || this.now() - cached.at > SNAPSHOT_LIFETIME_MS) return { status: "stale" }; + if (!validEdit(cached.state.model.text, edit)) return { status: "invalid" }; + this.busy = true; + const epoch = this.epoch; + let selected: DocsHostState | null = null; + let dispatched = false; + let operationId: string | undefined; + try { + const current = await this.readHost(); + if (!this.matches(cached.state, current) || epoch !== this.epoch) return { status: "stale" }; + const minimal = minimizeEdit(current.model.text, edit); + if (minimal.start === minimal.end && !minimal.replacement) return { status: "invalid" }; + this.host.select(current, minimal.start, minimal.end); + selected = { + ...current, + model: { ...current.model, anchor: minimal.start, focus: minimal.end }, + }; + const check = await this.readHost(); + if (epoch !== this.epoch || !this.matches(selected, check)) { + await this.restoreSelection(selected, current, epoch); + return { status: "stale" }; + } + const text = + current.model.text.slice(0, edit.start) + + edit.replacement + + current.model.text.slice(edit.end); + const expectedRaw = + current.model.raw.slice(0, current.model.offset) + + text + + current.model.raw.slice(current.model.offset + current.model.text.length); + operationId = this.createId(); + this.journal = { id: operationId, before: current, expectedRaw, edit, uncertain: true }; + this.tokens.clear(); + // Mark before dispatch: a handler can mutate and THEN throw. + dispatched = true; + this.host.paste(check, minimal.replacement); + const deadline = this.now() + 600; + do { + const observed = await this.readHost(); + if (this.isExpected(observed, this.journal)) { + this.journal.uncertain = false; + const naturalCaret = minimal.start + minimal.replacement.length; + // Respect intervening navigation/composition. Reposition only a known post-paste caret. + if ( + epoch === this.epoch && + observed.interaction === current.interaction && + observed.model.anchor === naturalCaret && + observed.model.focus === naturalCaret && + edit.cursorAfter !== naturalCaret + ) { + this.host.select(observed, edit.cursorAfter, edit.cursorAfter); + } + return { status: "applied", operationId }; + } + if (epoch !== this.epoch || this.now() >= deadline) break; + // Poll only for an acknowledgement; this never submits another edit. + await new Promise((resolve) => setTimeout(resolve, 20)); + } while (this.now() < deadline); + return { status: "unverified", operationId }; + } catch (error) { + if (dispatched) return { status: "unverified", operationId }; + if (selected) await this.restoreSelection(selected, cached.state, epoch); + return this.failure(error); + } finally { + this.busy = false; + } + } + + private async restoreSelection( + selected: DocsHostState, + original: DocsHostState, + epoch: number, + ): Promise { + try { + const current = await this.readHost(); + if (epoch === this.epoch && this.matches(selected, current)) { + this.host.select(current, original.model.anchor, original.model.focus); + } + } catch { + /* Never edit to repair a failed selection operation. */ + } + } + + private matches(a: DocsHostState, b: DocsHostState): boolean { + return ( + a.scope === b.scope && + a.input === b.input && + a.interaction === b.interaction && + sameModel(a.model, b.model) + ); + } + private isExpected(state: DocsHostState, journal: Journal): boolean { + return ( + state.scope === journal.before.scope && + state.input === journal.before.input && + state.model.raw === journal.expectedRaw + ); + } + private cache(state: DocsHostState): DocsSnapshot | null { + const token = this.createId(); + const snapshot = snapshotFor(state.model, state.scope, token); + if (!snapshot) return null; + for (const [key, value] of this.tokens) { + if (this.now() - value.at > SNAPSHOT_LIFETIME_MS) this.tokens.delete(key); + } + while (this.tokens.size >= 8) this.tokens.delete(this.tokens.keys().next().value!); + this.tokens.set(token, { state, at: this.now() }); + return snapshot; + } + private failure(error: unknown): DocsReply { + return { status: error instanceof DocsHostError ? error.status : "unavailable" }; + } + private async readHost(): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + this.host.read(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new DocsHostError("unavailable")), 800); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } + } +} diff --git a/src/adapters/chrome/content-script/google-docs/GoogleDocsView.ts b/src/adapters/chrome/content-script/google-docs/GoogleDocsView.ts new file mode 100644 index 00000000..52d2516f --- /dev/null +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsView.ts @@ -0,0 +1,233 @@ +import { SUPPORTED_LANGUAGES } from "@core/domain/lang"; +import { SuggestionMenuView } from "../suggestions/SuggestionMenuView"; +import { SuggestionMenuPresenter } from "../suggestions/SuggestionMenuPresenter"; +import { SuggestionPositioningService } from "../suggestions/SuggestionPositioningService"; +import { InlineSuggestionView } from "../suggestions/InlineSuggestionView"; +import { + DOCS_SESSION_ID, + snapshotContext, + type DocsSnapshot, + type DocsStatus, +} from "./GoogleDocsModel"; +import { getDocsCaret } from "./GoogleDocsEnvironment"; + +// Content runtime has no dependency on the options page's i18n engine. +const LABELS: Record = { + en: [ + "FluentTyper suggestions", + "Suggestion", + "Google Docs integration is unavailable.", + "The edit could not be verified. Check the document before reloading. It will not be retried.", + ], + pl: [ + "Podpowiedzi FluentTyper", + "Podpowiedź", + "Integracja z Dokumentami Google jest niedostępna.", + "Nie można potwierdzić zmiany. Sprawdź dokument przed odświeżeniem. Zmiana nie zostanie ponowiona.", + ], + de: [ + "FluentTyper-Vorschläge", + "Vorschlag", + "Die Google-Docs-Integration ist nicht verfügbar.", + "Die Änderung konnte nicht bestätigt werden. Prüfen Sie das Dokument vor dem Neuladen. Sie wird nicht wiederholt.", + ], + fr: [ + "Suggestions FluentTyper", + "Suggestion", + "L’intégration Google Docs est indisponible.", + "La modification n’a pas pu être vérifiée. Vérifiez le document avant de recharger. Elle ne sera pas répétée.", + ], + es: [ + "Sugerencias de FluentTyper", + "Sugerencia", + "La integración con Google Docs no está disponible.", + "No se pudo verificar el cambio. Revise el documento antes de recargar. No se repetirá.", + ], + pt: [ + "Sugestões do FluentTyper", + "Sugestão", + "A integração com o Google Docs está indisponível.", + "Não foi possível verificar a alteração. Confira o documento antes de recarregar. Ela não será repetida.", + ], + hr: [ + "Prijedlozi FluentTypera", + "Prijedlog", + "Integracija s Google dokumentima nije dostupna.", + "Izmjena nije potvrđena. Provjerite dokument prije ponovnog učitavanja. Izmjena se neće ponoviti.", + ], + el: [ + "Προτάσεις FluentTyper", + "Πρόταση", + "Η ενσωμάτωση στα Έγγραφα Google δεν είναι διαθέσιμη.", + "Η αλλαγή δεν επαληθεύτηκε. Ελέγξτε το έγγραφο πριν από την επαναφόρτωση. Δεν θα επαναληφθεί.", + ], + sv: [ + "FluentTyper-förslag", + "Förslag", + "Integrationen med Google Dokument är inte tillgänglig.", + "Ändringen kunde inte verifieras. Kontrollera dokumentet innan du laddar om. Ändringen upprepas inte.", + ], +}; +class DocsPositioning extends SuggestionPositioningService { + override getCaretRect(): DOMRect | null { + return getDocsCaret()?.rect ?? new DOMRect(16, Math.max(16, window.innerHeight - 80), 0, 20); + } +} +export interface DocsView { + render(suggestions: string[], index: number, snapshot: DocsSnapshot, language: string): boolean; + clear(): void; + status(status: DocsStatus): void; + dispose(): void; +} +export class GoogleDocsView implements DocsView { + private readonly elements = SuggestionMenuView.ensureMenu(); + private readonly presenter = new SuggestionMenuPresenter(new DocsPositioning()); + private readonly live = document.createElement("div"); + private readonly font = document.createElement("div"); + private readonly labels = LABELS[(navigator.language || "en").split(/[-_]/)[0]] ?? LABELS.en; + private target: HTMLElement | null = null; + constructor( + private readonly options: { + inline: boolean; + digits: boolean; + langHeader: boolean; + findToken: (text: string) => { token: string }; + accept: (index: number) => void; + }, + ) { + this.elements.menu.id = SuggestionMenuView.resolveHostId(DOCS_SESSION_ID); + this.live.setAttribute("role", "status"); + this.live.setAttribute("aria-live", "polite"); + this.live.setAttribute("aria-atomic", "true"); + this.live.setAttribute("data-ft-suggestion-owned", "true"); + this.live.style.cssText = + "position:fixed;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);pointer-events:none"; + document.body.appendChild(this.live); + this.font.setAttribute("data-ft-suggestion-owned", "true"); + this.font.setAttribute("aria-hidden", "true"); + this.font.style.cssText = "position:fixed;left:-9999px;top:0;visibility:hidden"; + document.body.appendChild(this.font); + // Preserve Docs focus. Shadow DOM composedPath is required for option hit-testing. + this.elements.list.addEventListener("pointerdown", (event) => { + if (event.button !== 0) return; + const item = (event.target as Element).closest("li[data-index]"); + if (!item) return; + event.preventDefault(); + event.stopPropagation(); + options.accept(Number(item.dataset.index)); + }); + this.elements.list.addEventListener("mousedown", (event) => event.preventDefault()); + } + render(suggestions: string[], index: number, snapshot: DocsSnapshot, language: string): boolean { + this.clear(true); + const measuredCaret = getDocsCaret(); + if (!suggestions.length) return false; + // Ambiguous collaborator/bidi geometry uses a fixed palette instead of guessing a caret. + const caret = measuredCaret ?? { + element: document.body, + rect: new DOMRect(16, Math.max(16, window.innerHeight - 80), 0, 20), + }; + this.target = caret.element; + const context = snapshotContext(snapshot); + const token = this.options.findToken(context.beforeCursor).token; + const candidate = suggestions[index]; + // Canvas cannot be DOM-mirrored. Never cover existing text with a guessed replacement. + // Inline is used for actual suffix insertion; other edits retain the same menu/acceptance. + const canGhost = + measuredCaret !== null && + this.options.inline && + snapshot.anchor === snapshot.focus && + candidate.startsWith(token) && + candidate.length > token.length && + !/[^\n\r]/.test(context.afterCursor.split("\n")[0]) && + !candidate.includes("\n") && + getComputedStyle(caret.element).direction !== "rtl"; + let visible = false; + if (canGhost) { + this.syncFont(caret.rect); + const ghost = InlineSuggestionView.render({ + target: this.font, + text: candidate.slice(token.length), + caretRect: caret.rect, + entryId: DOCS_SESSION_ID, + }); + if (ghost) { + ghost.setAttribute("aria-hidden", "true"); + // The anchor is a thin caret, not the text area's right edge. The generic + // presenter otherwise clamps this canvas ghost to the caret's 1px width. + ghost.style.maxWidth = `${Math.max(0, window.innerWidth - caret.rect.left - 8)}px`; + // Docs paints canvas glyphs ~1px lower per 17px line than a CSS line box does. + ghost.style.top = `${caret.rect.top + caret.rect.height * 0.06}px`; + ghost.style.whiteSpace = "pre"; + ghost.style.overflow = "hidden"; + ghost.style.textOverflow = "ellipsis"; + visible = true; + } + } + if (!visible) + visible = this.presenter.render({ + menuId: DOCS_SESSION_ID, + ...this.elements, + target: caret.element, + suggestions, + selectedIndex: index, + showShortcutDigits: this.options.digits, + menuHeader: this.options.langHeader ? (SUPPORTED_LANGUAGES[language] ?? language) : null, + mentionText: context.selectedText || token, + }); + const panel = SuggestionMenuView.resolvePanel(this.elements.menu); + panel.setAttribute("aria-label", this.labels[0]); + panel.setAttribute("dir", "auto"); + const announcement = `${this.labels[1]} ${index + 1}/${suggestions.length}: ${candidate}`; + if (this.live.textContent !== announcement) this.live.textContent = announcement; + this.elements.list.querySelectorAll("li").forEach((item) => item.setAttribute("dir", "auto")); + return visible; + } + /** Canvas text has no DOM style; mirror the toolbar's font, size and zoom onto a style host. */ + private syncFont(caretRect: DOMRect): void { + const family = document + .querySelector("#docs-font-family .goog-toolbar-menu-button-caption") + ?.textContent?.trim(); + const points = parseFloat( + document.querySelector("#fontSizeSelect input")?.value ?? "", + ); + const zoom = parseFloat( + document.querySelector("#zoomSelect input")?.value ?? "", + ); + const px = + points > 0 ? (points * 96 * (zoom > 0 ? zoom / 100 : 1)) / 72 : caretRect.height / 1.15; + this.font.style.fontFamily = family ? `${family}, Arial, sans-serif` : "Arial, sans-serif"; + this.font.style.fontSize = `${px}px`; + this.font.style.lineHeight = caretRect.height > 0 ? `${caretRect.height}px` : "normal"; + const pressed = (id: string) => + document.getElementById(id)?.getAttribute("aria-pressed") === "true"; + this.font.style.fontWeight = pressed("boldButton") ? "bold" : "normal"; + this.font.style.fontStyle = pressed("italicButton") ? "italic" : "normal"; + this.font.style.textDecoration = pressed("underlineButton") ? "underline" : "none"; + // ponytail: strikethrough and super/subscript are only in the Format menu; not mirrored. + const swatch = document.querySelector( + "#textColorButton [style*='border-bottom-color']", + ); + this.font.style.color = swatch?.style.borderBottomColor || "rgb(0, 0, 0)"; + } + clear(keepAnnouncement = false): void { + this.presenter.hide(this.elements.menu, this.elements.list, this.target ?? undefined); + InlineSuggestionView.removeForEntry(DOCS_SESSION_ID); + if (!keepAnnouncement) this.live.textContent = ""; + this.live.style.cssText = + "position:fixed;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);pointer-events:none"; + } + status(status: DocsStatus): void { + if (status !== "unverified" && status !== "unavailable") return; + this.live.textContent = status === "unverified" ? this.labels[3] : this.labels[2]; + // Visible diagnostic without modifying Google's editable DOM or moving focus. + this.live.style.cssText = + "position:fixed;bottom:12px;right:12px;max-width:360px;padding:12px;z-index:2147483647;background:Canvas;color:CanvasText;border:1px solid GrayText;font:14px/1.4 system-ui;pointer-events:none"; + } + dispose(): void { + this.clear(); + this.elements.menu.remove(); + this.live.remove(); + this.font.remove(); + } +} diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionPredictionCoordinator.ts b/src/adapters/chrome/content-script/suggestions/SuggestionPredictionCoordinator.ts index e6e8daf7..a4bd4571 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionPredictionCoordinator.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionPredictionCoordinator.ts @@ -12,6 +12,12 @@ import { const FIRST_CHAR_DEBOUNCE_CAP_MS = 12; const logger = createLogger("SuggestionPredictionCoordinator"); +export type PredictionSessionState = Pick< + SuggestionEntry, + "id" | "requestId" | "latestMentionText" | "latestMentionStart" | "pendingRequestTimer" +> & + Partial>; + interface SuggestionPredictionCoordinatorOptions { debounceByAction: { insert: number; @@ -53,7 +59,7 @@ export class SuggestionPredictionCoordinator { } public schedule( - entry: SuggestionEntry, + entry: PredictionSessionState, { force, clearSuggestions, @@ -71,7 +77,8 @@ export class SuggestionPredictionCoordinator { this.cancelPending(entry); const beforeCursor = - beforeCursorOverride ?? TextTargetAdapter.snapshot(entry.elem).beforeCursor; + beforeCursorOverride ?? + (entry.elem ? TextTargetAdapter.snapshot(entry.elem).beforeCursor : ""); const traceContext = createPredictionTraceContext(); if (force) { @@ -112,7 +119,7 @@ export class SuggestionPredictionCoordinator { } public reconcile( - entry: SuggestionEntry, + entry: PredictionSessionState, { clearSuggestions, inputAction, @@ -127,7 +134,8 @@ export class SuggestionPredictionCoordinator { ): void { this.cancelPending(entry); const beforeCursor = - beforeCursorOverride ?? TextTargetAdapter.snapshot(entry.elem).beforeCursor; + beforeCursorOverride ?? + (entry.elem ? TextTargetAdapter.snapshot(entry.elem).beforeCursor : ""); this.requestPrediction( entry, false, @@ -139,7 +147,7 @@ export class SuggestionPredictionCoordinator { ); } - public cancelPending(entry: SuggestionEntry): void { + public cancelPending(entry: PredictionSessionState): void { if (entry.pendingRequestTimer === null) { return; } @@ -148,7 +156,7 @@ export class SuggestionPredictionCoordinator { } public shouldProcessResponse( - entry: SuggestionEntry, + entry: PredictionSessionState, response: PredictionResponse, { isEntryFocused, @@ -172,7 +180,7 @@ export class SuggestionPredictionCoordinator { } private requestPrediction( - entry: SuggestionEntry, + entry: PredictionSessionState, force: boolean, clearSuggestions: () => void, inputAction?: PredictionInputAction, @@ -182,7 +190,9 @@ export class SuggestionPredictionCoordinator { ): void { const snapshot = beforeCursorOverride === undefined || afterCursorOverride === undefined - ? TextTargetAdapter.snapshot(entry.elem) + ? entry.elem + ? TextTargetAdapter.snapshot(entry.elem) + : null : null; const beforeCursor = beforeCursorOverride ?? snapshot?.beforeCursor ?? ""; const afterCursor = afterCursorOverride ?? snapshot?.afterCursor ?? ""; diff --git a/src/entries/content_script_main_world_start.ts b/src/entries/content_script_main_world_start.ts index 65d8daa9..2724755d 100644 --- a/src/entries/content_script_main_world_start.ts +++ b/src/entries/content_script_main_world_start.ts @@ -1,3 +1,6 @@ +import { installGoogleDocsMainWorld } from "@adapters/chrome/content-script/google-docs/GoogleDocsMainWorld"; import { installEarlyTabAcceptMainWorldBridge } from "@adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge"; installEarlyTabAcceptMainWorldBridge(); + +installGoogleDocsMainWorld(); diff --git a/tests/GoogleDocsModel.test.ts b/tests/GoogleDocsModel.test.ts new file mode 100644 index 00000000..5e3c3e40 --- /dev/null +++ b/tests/GoogleDocsModel.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from "bun:test"; +import { + isBoundary, + isGoogleDocsURL, + readModel, + snapshotFor, + snapshotContext, + planCompletion, + planGrammar, + minimizeEdit, + validEdit, + snapshotFrom, +} from "../src/adapters/chrome/content-script/google-docs/GoogleDocsModel"; +const token = (text: string) => ({ + token: text.match(/[^\s.,!?]*$/u)?.[0] ?? "", + start: text.length - (text.match(/[^\s.,!?]*$/u)?.[0].length ?? 0), +}); +const separator = (char: string) => /[\s.,!?]/u.test(char); +function snapshot(text: string, anchor = text.length, focus = anchor) { + return snapshotFor( + readModel(`\u0003${text}\n`, [{ anchor: anchor + 1, focus: focus + 1 }])!, + "doc?tab=t.1", + "token", + )!; +} +function complete(text: string, suggestion: string, anchor = text.length, focus = anchor) { + const edit = planCompletion(snapshot(text, anchor, focus), suggestion, token, separator)!; + return { edit, result: text.slice(0, edit.start) + edit.replacement + text.slice(edit.end) }; +} +describe("Google Docs logical edits", () => { + test("applies only to an actual Docs edit URL", () => { + for (const url of [ + "https://docs.google.com/document/d/123/edit", + "https://docs.google.com/document/u/0/d/123/edit?tab=t.0", + ]) + expect(isGoogleDocsURL(url)).toBe(true); + for (const url of [ + "https://evil.example/document/d/123/edit", + "https://docs.google.com/document/d/123/view", + "https://docs.google.com/spreadsheets/d/123/edit", + ]) + expect(isGoogleDocsURL(url)).toBe(false); + }); + test("removes only outer sentinels and preserves logical bidi offsets", () => { + const model = readModel("\u0003אבג\n", [{ anchor: 4, focus: 4 }])!; + expect(model.text).toBe("אבג"); + expect(model.anchor).toBe(3); + }); + test("rejects ambiguous selection metadata and multiple selections", () => { + expect(readModel("abc", [{ anchor: 1, focus: 1, start: 2, end: 2 }])).toBeNull(); + expect( + readModel("abc", [ + { anchor: 1, focus: 1 }, + { anchor: 2, focus: 2 }, + ]), + ).toBeNull(); + expect(readModel("abc", [{ anchor: NaN, focus: 1 }])).toBeNull(); + }); + test("preserves graphemes including accents emoji ZWJ flags and Indic clusters", () => { + for (const value of ["e\u0301", "😀", "👨‍👩‍👧‍👦", "🇵🇱", "क्ष"]) { + expect(isBoundary(value, 0)).toBe(true); + expect(isBoundary(value, value.length)).toBe(true); + for (let index = 1; index < value.length; index += 1) + expect(isBoundary(value, index)).toBe(false); + } + }); + test("performs spelling replacements rather than suffix-only completion", () => { + expect(complete("helo", "hello ").result).toBe("hello "); + }); + test("replaces the suffix under a mid-word caret", () => { + expect(complete("hellp world", "hello", 3).result).toBe("hello world"); + }); + test("expands multiline snippets without dropping whitespace", () => { + expect(complete("brb", "Hello,\n\nBartosz\n").result).toBe("Hello,\n\nBartosz\n"); + }); + test("next-word prediction never deletes the next word", () => { + expect(complete("Hello world", "beautiful ", 6).result).toBe("Hello beautiful world"); + }); + test("consumes a duplicate following space exactly once", () => { + expect(complete("hel world", "hello ", 3).result).toBe("hello world"); + }); + test("allows explicit reversed selection replacements", () => { + expect(complete("The bad phrase.", "good sentence", 14, 4).result).toBe("The good sentence."); + }); + test("does not edit across table/object control markers", () => { + const s = snapshot("a\ufffcb", 0, 3); + expect(planCompletion(s, "x", token, separator)).toBeNull(); + }); + test("allows grammar cursor placement inside paired brackets", () => { + expect( + planGrammar(snapshot("("), { + deleteBackwards: 1, + deleteForwards: 0, + replacement: "()", + cursorOffset: 1, + }), + ).toEqual({ start: 0, end: 1, replacement: "()", cursorAfter: 1 }); + }); + test("rejects grammar outside available context and invalid cursor offsets", () => { + expect( + planGrammar(snapshot("a"), { deleteBackwards: 2, deleteForwards: 0, replacement: "b" }), + ).toBeNull(); + expect( + planGrammar(snapshot("a"), { + deleteBackwards: 1, + deleteForwards: 0, + replacement: "b", + cursorOffset: 2, + }), + ).toBeNull(); + }); + test("minimizes replacement to retain unchanged formatting runs", () => { + expect( + minimizeEdit("helo", { start: 0, end: 4, replacement: "hello", cursorAfter: 5 }), + ).toEqual({ start: 3, end: 3, replacement: "l", cursorAfter: 5 }); + }); + test("minimal edits never split a grapheme", () => { + expect( + minimizeEdit("e\u0301x", { start: 0, end: 3, replacement: "e\u0300x", cursorAfter: 3 }), + ).toEqual({ start: 0, end: 2, replacement: "e\u0300", cursorAfter: 3 }); + }); + test("maps large bounded context back to full document offsets", () => { + const text = "hello ".repeat(5000) + "helo"; + const s = snapshot(text); + expect(s.text.length).toBeLessThanOrEqual(8192); + expect(snapshotContext(s).beforeCursor.endsWith("helo")).toBe(true); + const edit = planCompletion(s, "hello", token, separator)!; + expect(edit.start).toBe(text.length - 4); + }); + test("refuses a token truncated by either end of the context window", () => { + const longWord = "a".repeat(30000); + expect(planCompletion(snapshot(longWord, 29000), "word", token, separator)).toBeNull(); + expect(planCompletion(snapshot("before " + longWord, 8), "word", token, separator)).toBeNull(); + }); + test("rejects malformed page messages and excessive replacement size", () => { + expect( + snapshotFrom({ token: "x", scope: "d", text: "a", anchor: -1, focus: 0, windowStart: 0 }), + ).toBeNull(); + expect( + validEdit("a", { start: 0, end: 1, replacement: "x".repeat(16385), cursorAfter: 1 }), + ).toBe(false); + expect(validEdit("a", { start: 0.5, end: 1, replacement: "x", cursorAfter: 1 })).toBe(false); + }); + test("lands the caret after an existing following space when no space is appended", () => { + const text = "the wond next"; + const snapshot = { + token: "t", + scope: "s", + text, + windowStart: 0, + documentLength: text.length, + anchor: 8, + focus: 8, + }; + const findToken = (value: string) => { + const match = /\S+$/.exec(value); + return { token: match?.[0] ?? "", start: match ? match.index : value.length }; + }; + const isSeparator = (char: string) => /\s/.test(char); + expect(planCompletion(snapshot, "wonderful", findToken, isSeparator, true)).toEqual({ + start: 4, + end: 9, + replacement: "wonderful ", + cursorAfter: 14, + }); + expect(planCompletion(snapshot, "wonderful", findToken, isSeparator)).toEqual({ + start: 4, + end: 8, + replacement: "wonderful", + cursorAfter: 13, + }); + }); +}); diff --git a/tests/GoogleDocsTransaction.test.ts b/tests/GoogleDocsTransaction.test.ts new file mode 100644 index 00000000..774c68ff --- /dev/null +++ b/tests/GoogleDocsTransaction.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, test } from "bun:test"; +import { + GoogleDocsTransaction, + DocsHostError, + type DocsHostState, + type DocsHost, +} from "../src/adapters/chrome/content-script/google-docs/GoogleDocsTransaction"; +import { + readModel, + type DocsEdit, +} from "../src/adapters/chrome/content-script/google-docs/GoogleDocsModel"; +class Host implements DocsHost { + raw = "\u0003helo\n"; + anchor = 4; + focus = 4; + scope = "doc?tab=t.1"; + input = {}; + interaction = 0; + pastes = 0; + reads = 0; + selections: number[][] = []; + beforeRead: ((host: Host) => void) | null = null; + onPaste: ((host: Host, text: string) => void) | null = null; + async read(): Promise { + this.reads += 1; + this.beforeRead?.(this); + return { + model: readModel(this.raw, [{ anchor: this.anchor + 1, focus: this.focus + 1 }])!, + scope: this.scope, + input: this.input, + interaction: this.interaction, + }; + } + select(_state: DocsHostState, anchor: number, focus: number): void { + this.selections.push([anchor, focus]); + this.anchor = anchor; + this.focus = focus; + } + paste(_state: DocsHostState, text: string): void { + this.pastes += 1; + if (this.onPaste) this.onPaste(this, text); + else this.insert(text); + } + insert(text: string): void { + this.raw = this.raw.slice(0, this.anchor + 1) + text + this.raw.slice(this.focus + 1); + this.anchor += text.length; + this.focus = this.anchor; + } +} +const edit: DocsEdit = { start: 0, end: 4, replacement: "hello", cursorAfter: 5 }; +async function setup() { + const host = new Host(); + let id = 0; + const transaction = new GoogleDocsTransaction(host, () => `id-${++id}`); + const token = (await transaction.read()).snapshot!.token; + return { host, transaction, token }; +} +describe("Google Docs verified transactions", () => { + test("performs one minimal paste and places the final caret", async () => { + const { host, transaction, token } = await setup(); + expect((await transaction.apply(token, edit)).status).toBe("applied"); + expect(host.raw).toBe("\u0003hello\n"); + expect(host.pastes).toBe(1); + expect(host.selections).toEqual([ + [3, 3], + [5, 5], + ]); + expect((await transaction.read()).history).toBe("applied"); + }); + test("replayed tokens never produce a second insertion", async () => { + const { host, transaction, token } = await setup(); + await transaction.apply(token, edit); + expect((await transaction.apply(token, edit)).status).toBe("stale"); + expect(host.pastes).toBe(1); + }); + test("serializes overlapping apply requests", async () => { + const { host, transaction, token } = await setup(); + const first = transaction.apply(token, edit); + expect((await transaction.apply(token, edit)).status).toBe("busy"); + await first; + expect(host.pastes).toBe(1); + }); + for (const [name, mutate] of Object.entries({ + "remote text": (h: Host) => { + h.raw = "\u0003helo!\n"; + }, + caret: (h: Host) => { + h.anchor = 2; + h.focus = 2; + }, + tab: (h: Host) => { + h.scope = "doc?tab=t.2"; + }, + "input node": (h: Host) => { + h.input = {}; + }, + "user interaction": (h: Host) => { + h.interaction += 1; + }, + })) { + test(`rejects a stale ${name} snapshot without editing`, async () => { + const { host, transaction, token } = await setup(); + mutate(host); + expect((await transaction.apply(token, edit)).status).toBe("stale"); + expect(host.pastes).toBe(0); + }); + } + test("rechecks after selecting the replacement range", async () => { + const { host, transaction, token } = await setup(); + host.beforeRead = (h) => { + if (h.reads === 3) h.raw = "\u0003helo!\n"; + }; + expect((await transaction.apply(token, edit)).status).toBe("stale"); + expect(host.pastes).toBe(0); + }); + test("cancellation invalidates an already-issued snapshot", async () => { + const { host, transaction, token } = await setup(); + transaction.cancel(); + expect((await transaction.apply(token, edit)).status).toBe("stale"); + expect(host.pastes).toBe(0); + }); + test("never treats a silently ignored paste as success or retries it", async () => { + const { host, transaction, token } = await setup(); + host.onPaste = () => {}; + expect((await transaction.apply(token, edit)).status).toBe("unverified"); + expect((await transaction.read()).status).toBe("unverified"); + expect((await transaction.apply(token, edit)).status).toBe("unverified"); + expect(host.pastes).toBe(1); + }); + test("recovers on a late model acknowledgement without replay", async () => { + const { host, transaction, token } = await setup(); + host.onPaste = () => {}; + const reply = await transaction.apply(token, edit); + host.raw = "\u0003hello\n"; + host.anchor = 5; + host.focus = 5; + const observed = await transaction.read(); + expect(observed.status).toBe("ready"); + expect(observed.history).toBe("applied"); + expect(observed.operationId).toBe(reply.operationId); + expect(host.pastes).toBe(1); + }); + test("never retries a handler that mutates then throws", async () => { + const { host, transaction, token } = await setup(); + host.onPaste = (h, text) => { + h.insert(text); + throw new Error("after editing"); + }; + expect((await transaction.apply(token, edit)).status).toBe("unverified"); + expect((await transaction.read()).history).toBe("applied"); + expect(host.pastes).toBe(1); + }); + test("releases an unverified write after the next user interaction without replay", async () => { + const { host, transaction, token } = await setup(); + host.onPaste = () => {}; + await transaction.apply(token, edit); + expect((await transaction.read()).status).toBe("unverified"); + host.interaction += 1; + const reply = await transaction.read(); + expect(reply.status).toBe("ready"); + expect(reply.history).toBeUndefined(); + expect(host.pastes).toBe(1); + }); + test("cannot clear uncertain writes by toggling the extension", async () => { + const { host, transaction, token } = await setup(); + host.onPaste = () => {}; + await transaction.apply(token, edit); + transaction.cancel(); + expect((await transaction.read()).status).toBe("unverified"); + expect(host.pastes).toBe(1); + }); + test("observes exact native undo and redo without dispatching either", async () => { + const { host, transaction, token } = await setup(); + await transaction.apply(token, edit); + host.raw = "\u0003helo\n"; + host.anchor = 4; + host.focus = 4; + expect((await transaction.read()).history).toBe("undone"); + host.raw = "\u0003hello\n"; + host.anchor = 5; + host.focus = 5; + expect((await transaction.read()).history).toBe("applied"); + expect(host.pastes).toBe(1); + }); + test("rejects stale expiry with no write", async () => { + const host = new Host(); + let now = 0; + const transaction = new GoogleDocsTransaction( + host, + () => "token", + () => now, + ); + const token = (await transaction.read()).snapshot!.token; + now = 10001; + expect((await transaction.apply(token, edit)).status).toBe("stale"); + }); + test("reports missing API as unavailable", async () => { + const { host, transaction } = await setup(); + host.beforeRead = () => { + throw new DocsHostError("unavailable"); + }; + expect((await transaction.read()).status).toBe("unavailable"); + }); +}); diff --git a/tests/e2e/coverage-baseline-ids.json b/tests/e2e/coverage-baseline-ids.json index 06ca935f..45c5834f 100644 --- a/tests/e2e/coverage-baseline-ids.json +++ b/tests/e2e/coverage-baseline-ids.json @@ -1,6 +1,6 @@ { "version": 1, - "capturedAt": "2026-07-28", + "capturedAt": "2026-09-15", "baselineBehaviorIds": [ "install_page_reachable", "popup_page_loads", @@ -78,6 +78,62 @@ "shadow_dom_discovery", "shadow_dom_late_attach", "personalized_suggestion_ranking", - "personalization_local_privacy_controls" + "personalization_local_privacy_controls", + "gdocs_applies_only_to_an_actual_docs_edit_url", + "gdocs_removes_only_outer_sentinels_and_preserves_logical_bidi_offsets", + "gdocs_rejects_ambiguous_selection_metadata_and_multiple_selections", + "gdocs_preserves_graphemes_including_accents_emoji_zwj_flags_and_indic_clusters", + "gdocs_performs_spelling_replacements_rather_than_suffix_only_completion", + "gdocs_replaces_the_suffix_under_a_mid_word_caret", + "gdocs_expands_multiline_snippets_without_dropping_whitespace", + "gdocs_next_word_prediction_never_deletes_the_next_word", + "gdocs_consumes_a_duplicate_following_space_exactly_once", + "gdocs_allows_explicit_reversed_selection_replacements", + "gdocs_does_not_edit_across_table_object_control_markers", + "gdocs_allows_grammar_cursor_placement_inside_paired_brackets", + "gdocs_rejects_grammar_outside_available_context_and_invalid_cursor_offsets", + "gdocs_minimizes_replacement_to_retain_unchanged_formatting_runs", + "gdocs_minimal_edits_never_split_a_grapheme", + "gdocs_maps_large_bounded_context_back_to_full_document_offsets", + "gdocs_refuses_a_token_truncated_by_either_end_of_the_context_window", + "gdocs_rejects_malformed_page_messages_and_excessive_replacement_size", + "gdocs_performs_one_minimal_paste_and_places_the_final_caret", + "gdocs_replayed_tokens_never_produce_a_second_insertion", + "gdocs_serializes_overlapping_apply_requests", + "gdocs_rechecks_after_selecting_the_replacement_range", + "gdocs_cancellation_invalidates_an_already_issued_snapshot", + "gdocs_never_treats_a_silently_ignored_paste_as_success_or_retries_it", + "gdocs_recovers_on_a_late_model_acknowledgement_without_replay", + "gdocs_never_retries_a_handler_that_mutates_then_throws", + "gdocs_cannot_clear_uncertain_writes_by_toggling_the_extension", + "gdocs_observes_exact_native_undo_and_redo_without_dispatching_either", + "gdocs_rejects_stale_expiry_with_no_write", + "gdocs_reports_missing_api_as_unavailable", + "gdocs_real_typing_crosses_worlds_and_tab_commits_exactly_once", + "gdocs_non_prefix_spelling_and_mid_word_replacement", + "gdocs_title_and_comment_fields_retain_the_ordinary_fluenttyper_helper", + "gdocs_the_shared_menu_honors_existing_theme_variables", + "gdocs_digit_shortcuts_select_the_requested_suggestion", + "gdocs_arrow_navigation_and_enter_acceptance", + "gdocs_space_acceptance_follows_the_configured_setting", + "gdocs_multiline_snippets_preserve_their_line_breaks", + "gdocs_next_word_prediction_uses_the_shared_coordinator", + "gdocs_inline_mode_renders_an_owned_ghost_and_accepts_it", + "gdocs_inline_spelling_fallback_stays_selectable", + "gdocs_explicit_noncollapsed_selection_replacement", + "gdocs_mouse_acceptance_retains_editor_focus", + "gdocs_ignored_writes_are_never_learned_or_retried", + "gdocs_native_undo_observation_reverses_personalization_once", + "gdocs_ime_composition_suppresses_accepting_suggestions", + "gdocs_the_shared_local_grammar_catalog_performs_automatic_correction", + "gdocs_multiple_visible_carets_use_a_fixed_palette_without_choosing_a_collaborator", + "gdocs_rtl_text_uses_logical_offsets_and_a_direction_aware_menu", + "gdocs_offline_fixture_uses_no_network_service_for_local_suggestions", + "gdocs_document_tab_changes_invalidate_the_old_snapshot_before_editing", + "gdocs_late_acknowledged_writes_recover_and_learn_only_once", + "gdocs_disposing_removes_ui_and_keyboard_interception", + "gdocs_visible_suggestions_remain_usable_across_repeated_snapshot_refreshes", + "gdocs_a_colored_collaborator_caret_does_not_displace_the_local_caret_anchor", + "gdocs_releases_an_unverified_write_after_the_next_user_interaction_without_replay" ] } diff --git a/tests/e2e/coverage-matrix.json b/tests/e2e/coverage-matrix.json index e50f6978..1b1c2420 100644 --- a/tests/e2e/coverage-matrix.json +++ b/tests/e2e/coverage-matrix.json @@ -1,6 +1,6 @@ { "version": 1, - "capturedAt": "2026-07-28", + "capturedAt": "2026-09-15", "behaviors": [ { "id": "install_page_reachable", @@ -1303,6 +1303,622 @@ "test": "Personalized menu and inline ranking survives runtime restart and clears locally" } ] + }, + { + "id": "gdocs_applies_only_to_an_actual_docs_edit_url", + "description": "Google Docs model/transaction unit regression: applies only to an actual Docs edit URL", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "applies only to an actual Docs edit URL" + } + ] + }, + { + "id": "gdocs_releases_an_unverified_write_after_the_next_user_interaction_without_replay", + "description": "Google Docs model/transaction unit regression: releases an unverified write after the next user interaction without replay", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsTransaction.test.ts", + "test": "releases an unverified write after the next user interaction without replay" + } + ] + }, + { + "id": "gdocs_removes_only_outer_sentinels_and_preserves_logical_bidi_offsets", + "description": "Google Docs model/transaction unit regression: removes only outer sentinels and preserves logical bidi offsets", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "removes only outer sentinels and preserves logical bidi offsets" + } + ] + }, + { + "id": "gdocs_rejects_ambiguous_selection_metadata_and_multiple_selections", + "description": "Google Docs model/transaction unit regression: rejects ambiguous selection metadata and multiple selections", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "rejects ambiguous selection metadata and multiple selections" + } + ] + }, + { + "id": "gdocs_preserves_graphemes_including_accents_emoji_zwj_flags_and_indic_clusters", + "description": "Google Docs model/transaction unit regression: preserves graphemes including accents emoji ZWJ flags and Indic clusters", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "preserves graphemes including accents emoji ZWJ flags and Indic clusters" + } + ] + }, + { + "id": "gdocs_performs_spelling_replacements_rather_than_suffix_only_completion", + "description": "Google Docs model/transaction unit regression: performs spelling replacements rather than suffix-only completion", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "performs spelling replacements rather than suffix-only completion" + } + ] + }, + { + "id": "gdocs_replaces_the_suffix_under_a_mid_word_caret", + "description": "Google Docs model/transaction unit regression: replaces the suffix under a mid-word caret", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "replaces the suffix under a mid-word caret" + } + ] + }, + { + "id": "gdocs_expands_multiline_snippets_without_dropping_whitespace", + "description": "Google Docs model/transaction unit regression: expands multiline snippets without dropping whitespace", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "expands multiline snippets without dropping whitespace" + } + ] + }, + { + "id": "gdocs_next_word_prediction_never_deletes_the_next_word", + "description": "Google Docs model/transaction unit regression: next-word prediction never deletes the next word", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "next-word prediction never deletes the next word" + } + ] + }, + { + "id": "gdocs_consumes_a_duplicate_following_space_exactly_once", + "description": "Google Docs model/transaction unit regression: consumes a duplicate following space exactly once", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "consumes a duplicate following space exactly once" + } + ] + }, + { + "id": "gdocs_allows_explicit_reversed_selection_replacements", + "description": "Google Docs model/transaction unit regression: allows explicit reversed selection replacements", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "allows explicit reversed selection replacements" + } + ] + }, + { + "id": "gdocs_does_not_edit_across_table_object_control_markers", + "description": "Google Docs model/transaction unit regression: does not edit across table/object control markers", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "does not edit across table/object control markers" + } + ] + }, + { + "id": "gdocs_allows_grammar_cursor_placement_inside_paired_brackets", + "description": "Google Docs model/transaction unit regression: allows grammar cursor placement inside paired brackets", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "allows grammar cursor placement inside paired brackets" + } + ] + }, + { + "id": "gdocs_rejects_grammar_outside_available_context_and_invalid_cursor_offsets", + "description": "Google Docs model/transaction unit regression: rejects grammar outside available context and invalid cursor offsets", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "rejects grammar outside available context and invalid cursor offsets" + } + ] + }, + { + "id": "gdocs_minimizes_replacement_to_retain_unchanged_formatting_runs", + "description": "Google Docs model/transaction unit regression: minimizes replacement to retain unchanged formatting runs", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "minimizes replacement to retain unchanged formatting runs" + } + ] + }, + { + "id": "gdocs_minimal_edits_never_split_a_grapheme", + "description": "Google Docs model/transaction unit regression: minimal edits never split a grapheme", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "minimal edits never split a grapheme" + } + ] + }, + { + "id": "gdocs_maps_large_bounded_context_back_to_full_document_offsets", + "description": "Google Docs model/transaction unit regression: maps large bounded context back to full document offsets", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "maps large bounded context back to full document offsets" + } + ] + }, + { + "id": "gdocs_refuses_a_token_truncated_by_either_end_of_the_context_window", + "description": "Google Docs model/transaction unit regression: refuses a token truncated by either end of the context window", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "refuses a token truncated by either end of the context window" + } + ] + }, + { + "id": "gdocs_rejects_malformed_page_messages_and_excessive_replacement_size", + "description": "Google Docs model/transaction unit regression: rejects malformed page messages and excessive replacement size", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "rejects malformed page messages and excessive replacement size" + } + ] + }, + { + "id": "gdocs_performs_one_minimal_paste_and_places_the_final_caret", + "description": "Google Docs model/transaction unit regression: performs one minimal paste and places the final caret", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsTransaction.test.ts", + "test": "performs one minimal paste and places the final caret" + } + ] + }, + { + "id": "gdocs_replayed_tokens_never_produce_a_second_insertion", + "description": "Google Docs model/transaction unit regression: replayed tokens never produce a second insertion", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsTransaction.test.ts", + "test": "replayed tokens never produce a second insertion" + } + ] + }, + { + "id": "gdocs_serializes_overlapping_apply_requests", + "description": "Google Docs model/transaction unit regression: serializes overlapping apply requests", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsTransaction.test.ts", + "test": "serializes overlapping apply requests" + } + ] + }, + { + "id": "gdocs_rechecks_after_selecting_the_replacement_range", + "description": "Google Docs model/transaction unit regression: rechecks after selecting the replacement range", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsTransaction.test.ts", + "test": "rechecks after selecting the replacement range" + } + ] + }, + { + "id": "gdocs_cancellation_invalidates_an_already_issued_snapshot", + "description": "Google Docs model/transaction unit regression: cancellation invalidates an already-issued snapshot", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsTransaction.test.ts", + "test": "cancellation invalidates an already-issued snapshot" + } + ] + }, + { + "id": "gdocs_never_treats_a_silently_ignored_paste_as_success_or_retries_it", + "description": "Google Docs model/transaction unit regression: never treats a silently ignored paste as success or retries it", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsTransaction.test.ts", + "test": "never treats a silently ignored paste as success or retries it" + } + ] + }, + { + "id": "gdocs_recovers_on_a_late_model_acknowledgement_without_replay", + "description": "Google Docs model/transaction unit regression: recovers on a late model acknowledgement without replay", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsTransaction.test.ts", + "test": "recovers on a late model acknowledgement without replay" + } + ] + }, + { + "id": "gdocs_never_retries_a_handler_that_mutates_then_throws", + "description": "Google Docs model/transaction unit regression: never retries a handler that mutates then throws", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsTransaction.test.ts", + "test": "never retries a handler that mutates then throws" + } + ] + }, + { + "id": "gdocs_cannot_clear_uncertain_writes_by_toggling_the_extension", + "description": "Google Docs model/transaction unit regression: cannot clear uncertain writes by toggling the extension", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsTransaction.test.ts", + "test": "cannot clear uncertain writes by toggling the extension" + } + ] + }, + { + "id": "gdocs_observes_exact_native_undo_and_redo_without_dispatching_either", + "description": "Google Docs model/transaction unit regression: observes exact native undo and redo without dispatching either", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsTransaction.test.ts", + "test": "observes exact native undo and redo without dispatching either" + } + ] + }, + { + "id": "gdocs_rejects_stale_expiry_with_no_write", + "description": "Google Docs model/transaction unit regression: rejects stale expiry with no write", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsTransaction.test.ts", + "test": "rejects stale expiry with no write" + } + ] + }, + { + "id": "gdocs_reports_missing_api_as_unavailable", + "description": "Google Docs model/transaction unit regression: reports missing API as unavailable", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsTransaction.test.ts", + "test": "reports missing API as unavailable" + } + ] + }, + { + "id": "gdocs_real_typing_crosses_worlds_and_tab_commits_exactly_once", + "description": "Mock editor/browser fixture, not a live Google Docs claim: real typing crosses worlds and Tab commits exactly once", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "real typing crosses worlds and Tab commits exactly once" + } + ] + }, + { + "id": "gdocs_non_prefix_spelling_and_mid_word_replacement", + "description": "Mock editor/browser fixture, not a live Google Docs claim: non-prefix spelling and mid-word replacement", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "non-prefix spelling and mid-word replacement" + } + ] + }, + { + "id": "gdocs_title_and_comment_fields_retain_the_ordinary_fluenttyper_helper", + "description": "Mock editor/browser fixture, not a live Google Docs claim: title and comment fields retain the ordinary FluentTyper helper", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "title and comment fields retain the ordinary FluentTyper helper" + } + ] + }, + { + "id": "gdocs_the_shared_menu_honors_existing_theme_variables", + "description": "Mock editor/browser fixture, not a live Google Docs claim: the shared menu honors existing theme variables", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "the shared menu honors existing theme variables" + } + ] + }, + { + "id": "gdocs_digit_shortcuts_select_the_requested_suggestion", + "description": "Mock editor/browser fixture, not a live Google Docs claim: digit shortcuts select the requested suggestion", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "digit shortcuts select the requested suggestion" + } + ] + }, + { + "id": "gdocs_arrow_navigation_and_enter_acceptance", + "description": "Mock editor/browser fixture, not a live Google Docs claim: arrow navigation and Enter acceptance", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "arrow navigation and Enter acceptance" + } + ] + }, + { + "id": "gdocs_space_acceptance_follows_the_configured_setting", + "description": "Mock editor/browser fixture, not a live Google Docs claim: space acceptance follows the configured setting", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "space acceptance follows the configured setting" + } + ] + }, + { + "id": "gdocs_multiline_snippets_preserve_their_line_breaks", + "description": "Mock editor/browser fixture, not a live Google Docs claim: multiline snippets preserve their line breaks", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "multiline snippets preserve their line breaks" + } + ] + }, + { + "id": "gdocs_next_word_prediction_uses_the_shared_coordinator", + "description": "Mock editor/browser fixture, not a live Google Docs claim: next-word prediction uses the shared coordinator", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "next-word prediction uses the shared coordinator" + } + ] + }, + { + "id": "gdocs_inline_mode_renders_an_owned_ghost_and_accepts_it", + "description": "Mock editor/browser fixture, not a live Google Docs claim: inline mode renders an owned ghost and accepts it", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "inline mode renders an owned ghost and accepts it" + } + ] + }, + { + "id": "gdocs_inline_spelling_fallback_stays_selectable", + "description": "Mock editor/browser fixture, not a live Google Docs claim: inline spelling fallback stays selectable", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "inline spelling fallback stays selectable" + } + ] + }, + { + "id": "gdocs_explicit_noncollapsed_selection_replacement", + "description": "Mock editor/browser fixture, not a live Google Docs claim: explicit noncollapsed selection replacement", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "explicit noncollapsed selection replacement" + } + ] + }, + { + "id": "gdocs_mouse_acceptance_retains_editor_focus", + "description": "Mock editor/browser fixture, not a live Google Docs claim: mouse acceptance retains editor focus", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "mouse acceptance retains editor focus" + } + ] + }, + { + "id": "gdocs_ignored_writes_are_never_learned_or_retried", + "description": "Mock editor/browser fixture, not a live Google Docs claim: ignored writes are never learned or retried", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "ignored writes are never learned or retried" + } + ] + }, + { + "id": "gdocs_native_undo_observation_reverses_personalization_once", + "description": "Mock editor/browser fixture, not a live Google Docs claim: native undo observation reverses personalization once", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "native undo observation reverses personalization once" + } + ] + }, + { + "id": "gdocs_ime_composition_suppresses_accepting_suggestions", + "description": "Mock editor/browser fixture, not a live Google Docs claim: IME composition suppresses accepting suggestions", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "IME composition suppresses accepting suggestions" + } + ] + }, + { + "id": "gdocs_the_shared_local_grammar_catalog_performs_automatic_correction", + "description": "Mock editor/browser fixture, not a live Google Docs claim: the shared local grammar catalog performs automatic correction", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "the shared local grammar catalog performs automatic correction" + } + ] + }, + { + "id": "gdocs_multiple_visible_carets_use_a_fixed_palette_without_choosing_a_collaborator", + "description": "Mock editor/browser fixture, not a live Google Docs claim: multiple visible carets use a fixed palette without choosing a collaborator", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "multiple visible carets use a fixed palette without choosing a collaborator" + } + ] + }, + { + "id": "gdocs_a_colored_collaborator_caret_does_not_displace_the_local_caret_anchor", + "description": "Mock editor/browser fixture, not a live Google Docs claim: a colored collaborator caret does not displace the local caret anchor", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "a colored collaborator caret does not displace the local caret anchor" + } + ] + }, + { + "id": "gdocs_rtl_text_uses_logical_offsets_and_a_direction_aware_menu", + "description": "Mock editor/browser fixture, not a live Google Docs claim: RTL text uses logical offsets and a direction-aware menu", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "RTL text uses logical offsets and a direction-aware menu" + } + ] + }, + { + "id": "gdocs_offline_fixture_uses_no_network_service_for_local_suggestions", + "description": "Mock editor/browser fixture, not a live Google Docs claim: offline fixture uses no network service for local suggestions", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "offline fixture uses no network service for local suggestions" + } + ] + }, + { + "id": "gdocs_document_tab_changes_invalidate_the_old_snapshot_before_editing", + "description": "Mock editor/browser fixture, not a live Google Docs claim: document tab changes invalidate the old snapshot before editing", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "document tab changes invalidate the old snapshot before editing" + } + ] + }, + { + "id": "gdocs_late_acknowledged_writes_recover_and_learn_only_once", + "description": "Mock editor/browser fixture, not a live Google Docs claim: late acknowledged writes recover and learn only once", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "late acknowledged writes recover and learn only once" + } + ] + }, + { + "id": "gdocs_disposing_removes_ui_and_keyboard_interception", + "description": "Mock editor/browser fixture, not a live Google Docs claim: disposing removes UI and keyboard interception", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "disposing removes UI and keyboard interception" + } + ] + }, + { + "id": "gdocs_visible_suggestions_remain_usable_across_repeated_snapshot_refreshes", + "description": "Mock editor/browser fixture, not a live Google Docs claim: visible suggestions remain usable across repeated snapshot refreshes", + "coverage": [ + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "visible suggestions remain usable across repeated snapshot refreshes" + } + ] } ] } diff --git a/tests/e2e/fixtures/google-docs/controller.ts b/tests/e2e/fixtures/google-docs/controller.ts new file mode 100644 index 00000000..4e6b6e0c --- /dev/null +++ b/tests/e2e/fixtures/google-docs/controller.ts @@ -0,0 +1,72 @@ +if (!crypto.randomUUID) + Object.defineProperty(crypto, "randomUUID", { + value: () => + Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""), + }); +import { SuggestionManager } from "../../../../src/adapters/chrome/content-script/SuggestionManager"; +import { GoogleDocsAdapter } from "../../../../src/adapters/chrome/content-script/google-docs/GoogleDocsAdapter"; +import type { + SuggestionManagerOptions, + PredictionRequest, +} from "../../../../src/adapters/chrome/content-script/suggestions/types"; +const fixture = globalThis as unknown as { + docs: GoogleDocsAdapter; + generic: SuggestionManager; + requests: PredictionRequest[]; + events: string[]; + predictions: string[]; + startDocs: (options?: Partial) => void; +}; +fixture.predictions = ["hello", "help", "helmet"]; +fixture.events = []; +fixture.requests = []; +fixture.startDocs = (options = {}) => { + fixture.docs?.dispose(); + fixture.generic?.detachAllHelpers(); + const config: SuggestionManagerOptions = { + selectors: "textarea,input,[contentEditable]", + lang: "en_US", + autocomplete: false, + autocompleteOnEnter: true, + autocompleteOnTab: true, + minWordLengthToPredict: 0, + insertSpaceAfterAutocomplete: false, + selectByDigit: true, + displayLangHeader: true, + inline_suggestion: false, + preferNativeAutocomplete: true, + userDictionaryList: [], + enabledGrammarRules: [], + telemetry: { + recordSuggestionShown: () => fixture.events.push("shown"), + recordSuggestionAccepted: () => fixture.events.push("accepted"), + }, + personalization: { + recordSuggestionAccepted: () => { + fixture.events.push("learned"); + return "learning-id"; + }, + recordSuggestionReverted: () => fixture.events.push("reverted"), + }, + getPrediction: (context) => { + fixture.requests.push(context); + queueMicrotask(() => { + const response = { + ...context, + predictions: fixture.predictions.slice(), + lang: context.lang, + }; + if (context.suggestionId === -1) fixture.docs.fulfillPrediction(response); + else fixture.generic.fulfillPrediction(response); + }); + }, + ...options, + }; + fixture.generic = new SuggestionManager(config); + fixture.generic.queryAndAttachHelper(); + fixture.docs = new GoogleDocsAdapter(config); + fixture.docs.start(); +}; +fixture.startDocs(); diff --git a/tests/e2e/fixtures/google-docs/editor.html b/tests/e2e/fixtures/google-docs/editor.html new file mode 100644 index 00000000..b0d1f306 --- /dev/null +++ b/tests/e2e/fixtures/google-docs/editor.html @@ -0,0 +1,94 @@ + + + + + FluentTyper canvas adapter fixture (NOT Google Docs) + + +

This is a local synthetic editor, not Google Docs.

+ + +
+ + + + diff --git a/tests/e2e/fixtures/google-docs/main.ts b/tests/e2e/fixtures/google-docs/main.ts new file mode 100644 index 00000000..59a6499c --- /dev/null +++ b/tests/e2e/fixtures/google-docs/main.ts @@ -0,0 +1,33 @@ +if (!crypto.randomUUID) + Object.defineProperty(crypto, "randomUUID", { + value: () => + Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""), + }); +import { installGoogleDocsMainWorld } from "../../../../src/adapters/chrome/content-script/google-docs/GoogleDocsMainWorld"; +// Test-only URL facade. No real Docs page, private API, accounts or network are used. +// The real document, iframe, events and execution realms remain Chromium's. +const realTop = window.top!; +const topFacade: Window = new Proxy(realTop, { + get(target, key) { + if (key === "top") return topFacade; + if (key === "location") + return { href: (realTop as unknown as { fixtureScope: string }).fixtureScope }; + const value = Reflect.get(target, key, target); + return typeof value === "function" && !String(key).match(/^[A-Z]/) ? value.bind(target) : value; + }, +}); +const facade = + window === realTop + ? topFacade + : new Proxy(window, { + get(target, key) { + if (key === "top") return topFacade; + const value = Reflect.get(target, key, target); + return typeof value === "function" && !String(key).match(/^[A-Z]/) + ? value.bind(target) + : value; + }, + }); +installGoogleDocsMainWorld(facade); diff --git a/tests/e2e/google-docs.e2e.test.ts b/tests/e2e/google-docs.e2e.test.ts new file mode 100644 index 00000000..2ab41349 --- /dev/null +++ b/tests/e2e/google-docs.e2e.test.ts @@ -0,0 +1,354 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import puppeteer, { type Browser, type Page, type CDPSession } from "puppeteer"; +import { waitUntil } from "./e2e-helpers"; + +let browser: Browser; +let page: Page; +let cdp: CDPSession; +let isolated: number; + +let mainCode: string; +let controllerCode: string; +const fixturePath = `${import.meta.dir}/fixtures/google-docs/`; +async function evaluate(expression: string): Promise { + const result = await cdp.send("Runtime.evaluate", { + expression, + contextId: isolated, + returnByValue: true, + awaitPromise: true, + }); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value as T; +} +async function model(): Promise<{ text: string; pastes: number }> { + return page.evaluate( + () => (window as unknown as { model: { text: string; pastes: number } }).model, + ); +} +async function seed( + text: string, + predictions: string[], + options: object = {}, + anchor = text.length, + focus = anchor, +) { + await evaluate( + `predictions=${JSON.stringify(predictions)};events=[];requests=[];startDocs(${JSON.stringify(options)});`, + ); + await page.evaluate( + (state) => { + const f = window as unknown as { + setModel: (text: string, a: number, f: number) => void; + focusEditor: () => void; + }; + f.setModel(state.text, state.anchor, state.focus); + f.focusEditor(); + }, + { text, anchor, focus }, + ); + await evaluate("docs.triggerActiveSuggestion()"); + await waitUntil("Docs suggestions", () => + evaluate("document.querySelector('iframe').hasAttribute('data-ft-docs-key-state')"), + ); +} +async function expectText(text: string) { + await waitUntil("verified model text", async () => (await model()).text === text); +} + +describe("Google Docs cross-world fixture (not live Docs)", () => { + beforeAll(async () => { + const build = async (entry: string) => { + const result = await Bun.build({ + entrypoints: [fixturePath + entry], + target: "browser", + format: "iife", + // Resolve the repository alias explicitly in the test-runner build context. + plugins: [ + { + name: "fixture-repository-alias", + setup(build) { + build.onResolve({ filter: /^@core\// }, (args) => ({ + path: Bun.resolveSync( + `./src/core/${args.path.slice(6)}`, + `${import.meta.dir}/../..`, + ), + })); + }, + }, + ], + }); + if (!result.success) throw new Error(result.logs.join("\n")); + return result.outputs[0].text(); + }; + mainCode = await build("main.ts"); + controllerCode = await build("controller.ts"); + + browser = await puppeteer.launch({ + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + }); + beforeEach(async () => { + if (page) await page.close(); + page = await browser.newPage(); + await page.setContent(await Bun.file(fixturePath + "editor.html").text()); + await page.addScriptTag({ content: mainCode }); + await page.frames()[1].addScriptTag({ content: mainCode }); + cdp = await page.createCDPSession(); + const { frameTree } = await cdp.send("Page.getFrameTree"); + isolated = ( + await cdp.send("Page.createIsolatedWorld", { + frameId: frameTree.frame.id, + worldName: "fluenttyper-fixture", + }) + ).executionContextId; + await evaluate(controllerCode); + }); + afterAll(async () => { + await browser?.close(); + }); + test("real typing crosses worlds and Tab commits exactly once", async () => { + await evaluate('predictions=["hello"]'); + await page.keyboard.type("hel"); + await waitUntil("completion", () => + evaluate("document.querySelector('iframe').hasAttribute('data-ft-docs-key-state')"), + ); + await page.keyboard.press("Tab"); + await expectText("hello"); + await waitUntil("accepted statistics", () => evaluate('events.includes("accepted")')); + expect((await model()).pastes).toBe(1); + expect((await evaluate("events")).filter((v) => v === "accepted")).toHaveLength(1); + }); + test("visible suggestions remain usable across repeated snapshot refreshes", async () => { + await seed("hel", ["hello"]); + // Model-driven refreshes, not a fixed sleep: exceed the bridge's eight-token cache. + for (let index = 0; index < 12; index += 1) await evaluate("docs.refresh()"); + await page.keyboard.press("Tab"); + await expectText("hello"); + expect((await model()).pastes).toBe(1); + }); + test("non-prefix spelling and mid-word replacement", async () => { + await seed("hellp world", ["hello"], {}, 3); + await page.keyboard.press("Tab"); + await expectText("hello world"); + }); + test("title and comment fields retain the ordinary FluentTyper helper", async () => { + await evaluate('predictions=["hello"]'); + for (const selector of ["#title", "#comment"]) { + await page.click(selector); + await page.keyboard.type("hel"); + await waitUntil("generic popup", () => + page.evaluate(() => + Array.from(document.querySelectorAll('[id^="ft-menu-"]')).some( + (menu) => menu.id !== "ft-menu--1" && menu.style.display === "block", + ), + ), + ); + await page.keyboard.press("Tab"); + await waitUntil("generic completion", () => + page.$eval( + selector, + (el) => (el as HTMLInputElement | HTMLTextAreaElement).value === "hello", + ), + ); + } + expect((await model()).text).toBe(""); + expect((await model()).pastes).toBe(0); + }); + test("the shared menu honors existing theme variables", async () => { + await page.evaluate(() => { + document.documentElement.style.setProperty( + "--ft-theme-suggestion-bg-light", + "rgb(12, 34, 56)", + ); + }); + await seed("hel", ["hello"]); + expect( + await page.$eval( + "#ft-menu--1", + (el) => + getComputedStyle(el.shadowRoot!.querySelector(".ft-suggestion-panel")!).backgroundColor, + ), + ).toBe("rgb(12, 34, 56)"); + }); + test("digit shortcuts select the requested suggestion", async () => { + await seed("hel", ["hello", "help"]); + await page.keyboard.press("2"); + await expectText("help"); + }); + test("arrow navigation and Enter acceptance", async () => { + await seed("hel", ["hello", "help"]); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Enter"); + await expectText("help"); + }); + test("space acceptance follows the configured setting", async () => { + await seed("hel", ["hello "], { autocomplete: true }); + await page.keyboard.press("Space"); + await expectText("hello "); + }); + test("multiline snippets preserve their line breaks", async () => { + await seed("brb", ["Hello,\n\nBartosz\n"]); + await page.keyboard.press("Tab"); + await expectText("Hello,\n\nBartosz\n"); + }); + test("next-word prediction uses the shared coordinator", async () => { + await seed("Hello ", ["world "]); + await page.keyboard.press("Tab"); + await expectText("Hello world "); + expect((await evaluate>("requests")).at(-1)?.text).toContain("Hello "); + }); + test("inline mode renders an owned ghost and accepts it", async () => { + await seed("hel", ["hello"], { inline_suggestion: true }); + expect(await page.$(".ft-suggestion-inline")).not.toBeNull(); + expect( + await page.$eval(".ft-suggestion-inline", (el) => parseFloat(getComputedStyle(el).maxWidth)), + ).toBeGreaterThan(20); + expect(await page.$eval(".ft-suggestion-inline", (el) => getComputedStyle(el).whiteSpace)).toBe( + "pre", + ); + await page.keyboard.press("Tab"); + await expectText("hello"); + }); + test("inline spelling fallback stays selectable", async () => { + await seed("helo", ["hello"], { inline_suggestion: true }); + expect(await page.$(".ft-suggestion-inline")).toBeNull(); + await page.keyboard.press("Tab"); + await expectText("hello"); + }); + test("explicit noncollapsed selection replacement", async () => { + await seed("The bad phrase.", ["good sentence"], {}, 14, 4); + await page.keyboard.press("Tab"); + await expectText("The good sentence."); + }); + test("mouse acceptance retains editor focus", async () => { + await seed("hel", ["hello"]); + await page.locator("#ft-menu--1 >>> li").click(); + await expectText("hello"); + expect(await page.evaluate(() => document.activeElement?.tagName)).toBe("IFRAME"); + }); + test("ignored writes are never learned or retried", async () => { + await seed("helo", ["hello"]); + await page.evaluate(() => { + (window as unknown as { model: { mode: string } }).model.mode = "ignore"; + }); + await page.keyboard.press("Tab"); + await waitUntil( + "unverified notice", + async () => + (await page.$eval('[role="status"]', (el) => el.textContent))?.includes( + "could not be verified", + ) ?? false, + ); + expect((await evaluate("events")).includes("accepted")).toBe(false); + expect((await model()).pastes).toBe(1); + }); + test("native undo observation reverses personalization once", async () => { + await seed("helo", ["hello"]); + await page.keyboard.press("Tab"); + await expectText("hello"); + await waitUntil("learning", () => evaluate('events.includes("learned")')); + await page.evaluate(() => + (window as unknown as { setModel: (text: string) => void }).setModel("helo"), + ); + await waitUntil("reversal", () => evaluate('events.includes("reverted")')); + expect((await evaluate("events")).filter((v) => v === "reverted")).toHaveLength(1); + }); + test("IME composition suppresses accepting suggestions", async () => { + await seed("hel", ["hello"]); + await page + .frames()[1] + .evaluate(() => + document.activeElement!.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ), + ); + expect( + await page.$eval("iframe", (frame) => frame.hasAttribute("data-ft-docs-key-state")), + ).toBe(false); + }); + test("the shared local grammar catalog performs automatic correction", async () => { + await evaluate( + 'predictions=[];startDocs({enabledGrammarRules:["englishTypoWhitelistCorrection"]})', + ); + await page.keyboard.type("teh "); + await expectText("the "); + expect((await evaluate("events")).includes("accepted")).toBe(false); + }); + test("multiple visible carets use a fixed palette without choosing a collaborator", async () => { + await page.evaluate(() => { + const remote = document.querySelector(".kix-cursor-caret")!.cloneNode(true) as HTMLElement; + remote.style.left = "500px"; + document.body.appendChild(remote); + }); + await seed("hel", ["hello"], { inline_suggestion: true }); + expect(await page.$(".ft-suggestion-inline")).toBeNull(); + await page.keyboard.press("Tab"); + await expectText("hello"); + }); + test("a colored collaborator caret does not displace the local caret anchor", async () => { + await page.evaluate(() => { + const remote = document.querySelector(".kix-cursor-caret")!.cloneNode(true) as HTMLElement; + remote.style.left = "500px"; + remote.style.borderLeft = "2px solid rgb(66, 133, 244)"; + document.body.appendChild(remote); + }); + await seed("hel", ["hello"], { inline_suggestion: true }); + expect(await page.$(".ft-suggestion-inline")).not.toBeNull(); + await page.keyboard.press("Tab"); + await expectText("hello"); + }); + test("RTL text uses logical offsets and a direction-aware menu", async () => { + await page.$eval(".kix-cursor-caret", (element) => { + (element as HTMLElement).style.direction = "rtl"; + }); + await seed("של", ["שלום"], { inline_suggestion: true }); + await page.keyboard.press("Tab"); + await expectText("שלום"); + }); + test("offline fixture uses no network service for local suggestions", async () => { + await page.setOfflineMode(true); + await seed("hel", ["hello"]); + await page.keyboard.press("Tab"); + await expectText("hello"); + }); + test("document tab changes invalidate the old snapshot before editing", async () => { + await seed("helo", ["hello"]); + await page.evaluate(() => { + (window as unknown as { fixtureScope: string }).fixtureScope += "&changed-tab=2"; + }); + await page.keyboard.press("Tab"); + await waitUntil("fresh prediction after rejection", () => + evaluate("requests.length > 1"), + ); + expect((await model()).pastes).toBe(0); + }); + test("late acknowledged writes recover and learn only once", async () => { + await seed("helo", ["hello"]); + await page.evaluate(() => { + (window as unknown as { model: { mode: string } }).model.mode = "ignore"; + }); + await page.keyboard.press("Tab"); + await waitUntil( + "uncertain edit", + async () => + (await page.$eval('[role="status"]', (el) => el.textContent))?.includes( + "could not be verified", + ) ?? false, + ); + await page.evaluate(() => + (window as unknown as { setModel: (text: string) => void }).setModel("hello"), + ); + await waitUntil("late acceptance", () => evaluate('events.includes("accepted")')); + expect((await model()).pastes).toBe(1); + expect((await evaluate("events")).filter((v) => v === "accepted")).toHaveLength(1); + }); + test("disposing removes UI and keyboard interception", async () => { + await seed("hel", ["hello"]); + await evaluate("docs.dispose()"); + expect(await page.$("#ft-menu--1")).toBeNull(); + expect( + await page.$eval("iframe", (frame) => frame.hasAttribute("data-ft-docs-key-state")), + ).toBe(false); + }); +});