From 22653087617c18c0584fda0375d837fb8aa3b1e4 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Tue, 15 Sep 2026 08:53:16 +0200 Subject: [PATCH 01/10] Add Google Docs support via annotated-canvas bridge Adds a Google Docs adapter that reads the logical document model through the MAIN-world annotated-text API and applies suggestions as verified single-use edit transactions. Reuses the existing predictor, grammar, theme and local learning services. Includes model/transaction unit tests, a Chromium MAIN/isolated-world fixture suite, an operator-assisted live check script and docs. Activation is opt-in via the fluentTyperDocs=1 URL parameter pending live validation. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/test.yml | 3 + README.md | 4 +- docs/agents/runtime-features.md | 8 + docs/google-docs-integration.md | 158 +++++ package.json | 4 +- scripts/test-google-docs-live.ts | 192 ++++++ .../ContentRuntimeController.ts | 26 +- .../google-docs/GoogleDocsAdapter.ts | 583 +++++++++++++++++ .../google-docs/GoogleDocsBridgeClient.ts | 97 +++ .../google-docs/GoogleDocsEnvironment.ts | 72 +++ .../google-docs/GoogleDocsMainWorld.ts | 253 ++++++++ .../google-docs/GoogleDocsModel.ts | 336 ++++++++++ .../google-docs/GoogleDocsTransaction.ts | 227 +++++++ .../google-docs/GoogleDocsView.ts | 197 ++++++ .../SuggestionPredictionCoordinator.ts | 26 +- .../content_script_main_world_start.ts | 3 + tests/GoogleDocsModel.test.ts | 142 +++++ tests/GoogleDocsTransaction.test.ts | 193 ++++++ tests/e2e/coverage-baseline-ids.json | 58 +- tests/e2e/coverage-matrix.json | 596 +++++++++++++++++- tests/e2e/fixtures/google-docs/controller.ts | 72 +++ tests/e2e/fixtures/google-docs/editor.html | 87 +++ tests/e2e/fixtures/google-docs/main.ts | 33 + tests/e2e/google-docs.e2e.test.ts | 342 ++++++++++ 24 files changed, 3695 insertions(+), 17 deletions(-) create mode 100644 docs/google-docs-integration.md create mode 100644 scripts/test-google-docs-live.ts create mode 100644 src/adapters/chrome/content-script/google-docs/GoogleDocsAdapter.ts create mode 100644 src/adapters/chrome/content-script/google-docs/GoogleDocsBridgeClient.ts create mode 100644 src/adapters/chrome/content-script/google-docs/GoogleDocsEnvironment.ts create mode 100644 src/adapters/chrome/content-script/google-docs/GoogleDocsMainWorld.ts create mode 100644 src/adapters/chrome/content-script/google-docs/GoogleDocsModel.ts create mode 100644 src/adapters/chrome/content-script/google-docs/GoogleDocsTransaction.ts create mode 100644 src/adapters/chrome/content-script/google-docs/GoogleDocsView.ts create mode 100644 tests/GoogleDocsModel.test.ts create mode 100644 tests/GoogleDocsTransaction.test.ts create mode 100644 tests/e2e/fixtures/google-docs/controller.ts create mode 100644 tests/e2e/fixtures/google-docs/editor.html create mode 100644 tests/e2e/fixtures/google-docs/main.ts create mode 100644 tests/e2e/google-docs.e2e.test.ts 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..e464514d 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 is currently opt-in: append `fluentTyperDocs=1` to the document's edit URL and reload. 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..c10c5c5b --- /dev/null +++ b/docs/google-docs-integration.md @@ -0,0 +1,158 @@ +# 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 +``` + +Use a dedicated browser profile and a NEW, EMPTY, DISPOSABLE document. Load the +unpacked build, enable FluentTyper on docs.google.com, and append +`fluentTyperDocs=1` to the document's edit URL. Reload after enabling the parameter: +the annotation bootstrap must run at `document_start`. Remove the parameter and +reload to return to the previous behavior. Normal title/comment helpers stay active. + +The parameter is deliberately retained as a release gate. 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. | + +## 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. + +Before removing the release gate, obtain actual evidence for the owning extension +IDs, Chrome/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..1c03f330 --- /dev/null +++ b/scripts/test-google-docs-live.ts @@ -0,0 +1,192 @@ +/** 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."); + } + url.searchParams.set("fluentTyperDocs", "1"); + 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 || + current.searchParams.get("fluentTyperDocs") !== "1" + ) { + throw new Error( + "The selected page is not the specified opted-in 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..da0bb152 --- /dev/null +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsAdapter.ts @@ -0,0 +1,583 @@ +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); + if (this.uncertain) { + this.clearVisual(); + this.view.status("unverified"); + return; + } + 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), + ) + ); + } + 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..a9025fac --- /dev/null +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsEnvironment.ts @@ -0,0 +1,72 @@ +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; + const element = inner?.activeElement as HTMLElement | null; + if (!inner || !element?.isContentEditable || element.getAttribute("aria-readonly") === "true") + return null; + return { frame: iframe, document: inner, element }; + } catch { + return null; + } +} + +/** Read visual geometry only; never move selection to measure or choose by collaborator color. */ +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, + ); + // Bidi can show coincident caret fragments. Deduplicate geometry, not identity by color. + const unique = visible.filter( + (item, index) => + !visible + .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..f5cb9966 --- /dev/null +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsMainWorld.ts @@ -0,0 +1,253 @@ +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); + }, + 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(); + data.setData("text/plain", text); + // 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..11ed46a8 --- /dev/null +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsModel.ts @@ -0,0 +1,336 @@ +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"; +} + +/** Release gate remains opt-in until the live editor acceptance matrix passes. */ +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) && + url.searchParams.get("fluentTyperDocs") === "1" + ); + } 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, +): 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; + } + 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..ef7efccf --- /dev/null +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsTransaction.ts @@ -0,0 +1,227 @@ +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)) + return { status: "unverified", operationId: this.journal.id }; + // Late acknowledgement: reopen only after the exact expected model is observed. + this.journal.uncertain = false; + } + 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..2cbe6f5b --- /dev/null +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsView.ts @@ -0,0 +1,197 @@ +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 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); + // 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) { + const ghost = InlineSuggestionView.render({ + target: caret.element, + 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`; + 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; + } + 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(); + } +} 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..f4a2dfc7 --- /dev/null +++ b/tests/GoogleDocsModel.test.ts @@ -0,0 +1,142 @@ +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("opt-in only applies to an actual Docs edit URL", () => { + expect(isGoogleDocsURL("https://docs.google.com/document/d/123/edit?fluentTyperDocs=1")).toBe( + true, + ); + for (const url of [ + "https://evil.example/document/d/123/edit?fluentTyperDocs=1", + "https://docs.google.com/document/d/123/edit", + "https://docs.google.com/document/d/123/view?fluentTyperDocs=1", + ]) + 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); + }); +}); diff --git a/tests/GoogleDocsTransaction.test.ts b/tests/GoogleDocsTransaction.test.ts new file mode 100644 index 00000000..467b4f5a --- /dev/null +++ b/tests/GoogleDocsTransaction.test.ts @@ -0,0 +1,193 @@ +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("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..f3d7b93d 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,60 @@ "shadow_dom_discovery", "shadow_dom_late_attach", "personalized_suggestion_ranking", - "personalization_local_privacy_controls" + "personalization_local_privacy_controls", + "gdocs_opt_in_only_applies_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" ] } diff --git a/tests/e2e/coverage-matrix.json b/tests/e2e/coverage-matrix.json index e50f6978..fdff235a 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,600 @@ "test": "Personalized menu and inline ranking survives runtime restart and clears locally" } ] + }, + { + "id": "gdocs_opt_in_only_applies_to_an_actual_docs_edit_url", + "description": "Google Docs model/transaction unit regression: opt-in only applies to an actual Docs edit URL", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsModel.test.ts", + "test": "opt-in only applies to an actual Docs edit URL" + } + ] + }, + { + "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_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..efe553a9 --- /dev/null +++ b/tests/e2e/fixtures/google-docs/editor.html @@ -0,0 +1,87 @@ + + + + + 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..df4ab48e --- /dev/null +++ b/tests/e2e/google-docs.e2e.test.ts @@ -0,0 +1,342 @@ +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("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); + }); +}); From 5e3acf0a83b058de436f9ff5663a6234add348db Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Tue, 15 Sep 2026 09:14:54 +0200 Subject: [PATCH 02/10] Fix Google Docs edits against the live editor Verified in Chrome against real Google Docs. Docs' setSelection blurs the editable inside the text-event iframe, so the bridge now refocuses it before pasting and accepts a blurred editable while the frame is focused. Docs strips edge ASCII spaces from a plain-text paste but converts NBSP to a space, so trailing spaces are sent as NBSP. An unverified write now stops blocking after the next trusted user interaction instead of locking the adapter for the session. The fixture editor mirrors the paste normalization. Co-Authored-By: Claude Fable 5.1 --- docs/google-docs-integration.md | 10 ++++++++++ .../content-script/google-docs/GoogleDocsAdapter.ts | 7 ++----- .../google-docs/GoogleDocsEnvironment.ts | 10 +++++++--- .../content-script/google-docs/GoogleDocsMainWorld.ts | 9 ++++++++- .../google-docs/GoogleDocsTransaction.ts | 11 ++++++++--- tests/e2e/fixtures/google-docs/editor.html | 8 +++++++- 6 files changed, 42 insertions(+), 13 deletions(-) diff --git a/docs/google-docs-integration.md b/docs/google-docs-integration.md index c10c5c5b..b1f10ec0 100644 --- a/docs/google-docs-integration.md +++ b/docs/google-docs-integration.md @@ -70,6 +70,16 @@ site configuration, and personalization settings keep their existing code paths. | 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. diff --git a/src/adapters/chrome/content-script/google-docs/GoogleDocsAdapter.ts b/src/adapters/chrome/content-script/google-docs/GoogleDocsAdapter.ts index da0bb152..c80d587c 100644 --- a/src/adapters/chrome/content-script/google-docs/GoogleDocsAdapter.ts +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsAdapter.ts @@ -264,11 +264,8 @@ export class GoogleDocsAdapter { } this.failureStatus = null; this.observeHistory(reply); - if (this.uncertain) { - this.clearVisual(); - this.view.status("unverified"); - return; - } + // 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()) { diff --git a/src/adapters/chrome/content-script/google-docs/GoogleDocsEnvironment.ts b/src/adapters/chrome/content-script/google-docs/GoogleDocsEnvironment.ts index a9025fac..44a88e0d 100644 --- a/src/adapters/chrome/content-script/google-docs/GoogleDocsEnvironment.ts +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsEnvironment.ts @@ -26,9 +26,13 @@ export function getDocsInput(doc: Document = document): DocsInput | null { try { const iframe = frame as HTMLIFrameElement; const inner = iframe.contentDocument; - const element = inner?.activeElement as HTMLElement | null; - if (!inner || !element?.isContentEditable || element.getAttribute("aria-readonly") === "true") - return null; + // 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; diff --git a/src/adapters/chrome/content-script/google-docs/GoogleDocsMainWorld.ts b/src/adapters/chrome/content-script/google-docs/GoogleDocsMainWorld.ts index f5cb9966..2dd3fbf7 100644 --- a/src/adapters/chrome/content-script/google-docs/GoogleDocsMainWorld.ts +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsMainWorld.ts @@ -116,6 +116,8 @@ export function installGoogleDocsMainWorld(win: Window = window): () => void { 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); @@ -130,7 +132,12 @@ export function installGoogleDocsMainWorld(win: Window = window): () => void { const realm = input.document.defaultView; if (!realm) throw new DocsHostError("inactive"); const data = new realm.DataTransfer(); - data.setData("text/plain", text); + // 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", { diff --git a/src/adapters/chrome/content-script/google-docs/GoogleDocsTransaction.ts b/src/adapters/chrome/content-script/google-docs/GoogleDocsTransaction.ts index ef7efccf..a1ad6438 100644 --- a/src/adapters/chrome/content-script/google-docs/GoogleDocsTransaction.ts +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsTransaction.ts @@ -68,10 +68,15 @@ export class GoogleDocsTransaction { const state = await this.readHost(); if (epoch !== this.epoch) return { status: "cancelled" }; if (this.journal?.uncertain) { - if (!this.isExpected(state, this.journal)) + 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 }; - // Late acknowledgement: reopen only after the exact expected model is observed. - this.journal.uncertain = false; + } 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" }; diff --git a/tests/e2e/fixtures/google-docs/editor.html b/tests/e2e/fixtures/google-docs/editor.html index efe553a9..a014d94f 100644 --- a/tests/e2e/fixtures/google-docs/editor.html +++ b/tests/e2e/fixtures/google-docs/editor.html @@ -77,7 +77,13 @@ model.pastes++; event.preventDefault(); if (model.mode === "ignore") return; - replace(event.clipboardData.getData("text/plain")); + // Mirror live Docs: plain edge spaces are stripped, NBSP becomes a regular space. + replace( + event.clipboardData + .getData("text/plain") + .replace(/^ +| +$/g, "") + .replace(/\u00a0/g, " "), + ); }); window.focusEditor = () => input.focus(); focusEditor(); From 1d27bc5aa04baad5a28798a5bd80b8cf2b1073e8 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Tue, 15 Sep 2026 09:20:44 +0200 Subject: [PATCH 03/10] Enable Google Docs support on every document edit URL Drops the fluentTyperDocs=1 opt-in parameter after live verification in Chrome; per-site enable/disable still governs docs.google.com. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- docs/google-docs-integration.md | 21 ++++++++++--------- scripts/test-google-docs-live.ts | 9 ++------ .../google-docs/GoogleDocsModel.ts | 5 ++--- tests/GoogleDocsModel.test.ts | 14 +++++++------ tests/e2e/fixtures/google-docs/editor.html | 3 +-- 6 files changed, 25 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index e464514d..0b50fcd5 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Site profiles never bypass domain enable/disable logic. If a domain is blocked b FluentTyper works on most websites, including Google Docs. -Google Docs support is currently opt-in: append `fluentTyperDocs=1` to the document's edit URL and reload. 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. +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/google-docs-integration.md b/docs/google-docs-integration.md index b1f10ec0..db7e2d9e 100644 --- a/docs/google-docs-integration.md +++ b/docs/google-docs-integration.md @@ -25,14 +25,14 @@ bun run test:e2e:docs bun run build --platform=chrome ``` -Use a dedicated browser profile and a NEW, EMPTY, DISPOSABLE document. Load the -unpacked build, enable FluentTyper on docs.google.com, and append -`fluentTyperDocs=1` to the document's edit URL. Reload after enabling the parameter: -the annotation bootstrap must run at `document_start`. Remove the parameter and -reload to return to the previous behavior. Normal title/comment helpers stay active. - -The parameter is deliberately retained as a release gate. Passing a build in -`production` mode does not mean this private-API integration is production-certified. +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. @@ -148,8 +148,9 @@ 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. -Before removing the release gate, obtain actual evidence for the owning extension -IDs, Chrome/Edge/Firefox, all supported keyboard settings, snippets/dynamic variables, +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, diff --git a/scripts/test-google-docs-live.ts b/scripts/test-google-docs-live.ts index 1c03f330..0b795096 100644 --- a/scripts/test-google-docs-live.ts +++ b/scripts/test-google-docs-live.ts @@ -47,7 +47,6 @@ async function main(): Promise { ) { throw new Error("A real Google Docs document edit URL is required."); } - url.searchParams.set("fluentTyperDocs", "1"); const extension = path.resolve(extensionValue); const profile = path.resolve(profileValue); await mkdir(profile, { recursive: true }); @@ -67,13 +66,9 @@ async function main(): Promise { "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 || - current.searchParams.get("fluentTyperDocs") !== "1" - ) { + if (current.origin !== url.origin || current.pathname !== url.pathname) { throw new Error( - "The selected page is not the specified opted-in test document. No typing was attempted.", + "The selected page is not the specified test document. No typing was attempted.", ); } const before = await read(page); diff --git a/src/adapters/chrome/content-script/google-docs/GoogleDocsModel.ts b/src/adapters/chrome/content-script/google-docs/GoogleDocsModel.ts index 11ed46a8..03c42d3e 100644 --- a/src/adapters/chrome/content-script/google-docs/GoogleDocsModel.ts +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsModel.ts @@ -53,14 +53,13 @@ export interface DocsReply { history?: "applied" | "undone"; } -/** Release gate remains opt-in until the live editor acceptance matrix passes. */ +/** 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) && - url.searchParams.get("fluentTyperDocs") === "1" + /^\/document\/(?:u\/\d+\/)?d\/[\w-]+\/edit\/?$/.test(url.pathname) ); } catch { return false; diff --git a/tests/GoogleDocsModel.test.ts b/tests/GoogleDocsModel.test.ts index f4a2dfc7..e5d39939 100644 --- a/tests/GoogleDocsModel.test.ts +++ b/tests/GoogleDocsModel.test.ts @@ -28,14 +28,16 @@ function complete(text: string, suggestion: string, anchor = text.length, focus return { edit, result: text.slice(0, edit.start) + edit.replacement + text.slice(edit.end) }; } describe("Google Docs logical edits", () => { - test("opt-in only applies to an actual Docs edit URL", () => { - expect(isGoogleDocsURL("https://docs.google.com/document/d/123/edit?fluentTyperDocs=1")).toBe( - true, - ); + test("applies only to an actual Docs edit URL", () => { for (const url of [ - "https://evil.example/document/d/123/edit?fluentTyperDocs=1", "https://docs.google.com/document/d/123/edit", - "https://docs.google.com/document/d/123/view?fluentTyperDocs=1", + "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); }); diff --git a/tests/e2e/fixtures/google-docs/editor.html b/tests/e2e/fixtures/google-docs/editor.html index a014d94f..a925e600 100644 --- a/tests/e2e/fixtures/google-docs/editor.html +++ b/tests/e2e/fixtures/google-docs/editor.html @@ -28,8 +28,7 @@ style="position: fixed; left: 0; top: 500px; width: 100px; height: 40px" >