Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ export class GoogleDocsAdapter {
// Insertion keystrokes seen since grammar last caught up with a quiet document. Replay
// trusts a text diff only as far as this accounts for it; a paste zeroes it.
private typed = 0;
// An edit since the last dismiss. Only an edit (or an explicit request) asks for
// suggestions: a click or an arrow key that moves the caret also reads as "changed",
// because dismiss() forgets the snapshot.
private edited = false;
private visible = false;
private reviewActive = false;
private readonly reviewSourceListeners = new Set<() => void>();
Expand All @@ -217,6 +221,7 @@ export class GoogleDocsAdapter {
private readonly compositionEnd = () => {
this.reviewSourceChanged();
this.composing = false;
this.edited = true;
this.scheduleRefresh("insert", 60);
};
private readonly navigationListener = (event: Event) => {
Expand Down Expand Up @@ -529,7 +534,9 @@ export class GoogleDocsAdapter {
this.updateKeyState();
return;
}
if (snapshot.anchor !== snapshot.focus && !force) {
const edited = this.edited;
this.edited = false;
if ((snapshot.anchor !== snapshot.focus || !edited) && !force) {
this.clearVisual();
return;
}
Expand Down Expand Up @@ -699,6 +706,8 @@ export class GoogleDocsAdapter {
else if (reply.status === "unverified") this.uncertain = tracked;
this.snapshot = null;
this.view.status(reply.status);
// What follows an accepted word or a correction is still typing.
this.edited = true;
if (!this.uncertain) void this.refresh();
}
private learn(acceptance: Acceptance | null): string {
Expand Down Expand Up @@ -839,6 +848,7 @@ export class GoogleDocsAdapter {
if (triggers.includes("paste")) this.typed = 0;
else if (triggers.includes("insertChar")) this.typed += 1;
this.pendingAction = action;
this.edited = true;
// The host applies the keystroke later in this same dispatch, so the earliest
// correct moment to read it back is the next task, not a fixed settle delay.
// Waiting longer only lets the following keystroke cancel this pass and take the
Expand Down Expand Up @@ -928,6 +938,7 @@ export class GoogleDocsAdapter {
this.bridge.cancel();
this.clearVisual();
this.snapshot = null;
this.edited = false;
this.failureStatus = null;
if (this.refreshTimer !== null) clearTimeout(this.refreshTimer);
if (this.idleTimer !== null) clearTimeout(this.idleTimer);
Expand Down
50 changes: 50 additions & 0 deletions tests/GoogleDocsAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,53 @@ test("the menu header keeps the predicted language when the layout renders it ag

expect(render.mock.calls.map((call) => (call as unknown[])[3])).toEqual(["de_DE", "de_DE"]);
});

// Regression: a click (or an arrow key) that only moved the caret opened the menu. The
// dismiss on pointerdown forgot the snapshot, so the next poll read looked like an edit.
test("a caret move without an edit asks for no suggestions; an edit does", async () => {
const frame = document.createElement("iframe");
frame.className = "docs-texteventtarget-iframe";
frame.tabIndex = 0;
document.body.append(frame);
frame.contentDocument!.body.innerHTML = '<div contenteditable="true"></div>';
frame.focus();

const adapter = new GoogleDocsAdapter({
lang: "en_US",
minWordLengthToPredict: 1,
getPrediction: () => undefined,
enabledGrammarRules: [],
} as unknown as SuggestionManagerOptions);
const internals = adapter as unknown as {
snapshot: unknown;
bridge: { read: () => Promise<unknown> };
prediction: { schedule: (...args: unknown[]) => void };
navigationListener: (event: Event) => void;
queueEdit: (action: string, triggers: string[]) => void;
refresh: () => Promise<void>;
};
const at = (caret: number) => ({
scope: "s",
text: "hello world",
windowStart: 0,
documentLength: 11,
caret,
anchor: caret,
focus: caret,
token: `t${caret}`,
});
internals.snapshot = at(11);
internals.bridge.read = async () => ({ status: "ready", snapshot: at(3) });
const schedule = jest.fn();
internals.prediction.schedule = schedule;

internals.navigationListener(new Event("pointerdown"));
await internals.refresh();
expect(schedule).not.toHaveBeenCalled();

internals.queueEdit("insert", ["insertChar"]);
internals.bridge.read = async () => ({ status: "ready", snapshot: at(4) });
await internals.refresh();
expect(schedule).toHaveBeenCalledTimes(1);
adapter.dispose();
});
21 changes: 21 additions & 0 deletions tests/e2e/coverage-matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -4767,6 +4767,27 @@
}
]
},
{
"id": "caret_move_shows_no_suggestion",
"description": "A click or an arrow key that only moves the caret over existing text shows no suggestion menu and no inline ghost. Only an edit or an explicit request asks for suggestions. In Google Docs, the dismiss on a click forgets the snapshot, and the next poll must not read the moved caret as an edit.",
"coverage": [
{
"layer": "unit",
"file": "tests/GoogleDocsAdapter.test.ts",
"test": "a caret move without an edit asks for no suggestions; an edit does"
},
{
"layer": "integration",
"file": "tests/e2e/google-docs.e2e.test.ts",
"test": "a click or an arrow key that only moves the caret requests no suggestions"
},
{
"layer": "e2e-full",
"file": "tests/e2e/full.e2e.test.ts",
"test": "A click or an arrow key that only moves the caret shows no suggestion in $name (inline: $inline)"
}
]
},
{
"id": "stale_predictions_after_caret_move",
"description": "Predictions are for one caret place. When the page moves the caret without a text change (for example after focus), a prediction response for the old place is not shown, and a visible contenteditable menu is dismissed, so Space or Tab cannot write a stale word at the new place.",
Expand Down
159 changes: 159 additions & 0 deletions tests/e2e/full.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7802,6 +7802,165 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => {
suiteTimeout(60000, 90000),
);

test.each(
[
...caretEditors,
{
name: "Notion-like page",
options: { reviewEditor: "notion" as const },
selector: "",
surface: undefined,
setup: undefined,
fixture: "notion" as const,
},
].flatMap((entry) => [false, true].map((inline) => ({ ...entry, inline }))),
)(
"A click or an arrow key that only moves the caret shows no suggestion in $name (inline: $inline)",
async ({ options, selector: fieldSelector, surface: surfaceKind, setup, fixture, inline }) => {
await setGrammarRules(worker, []);
await setSetting(worker, KEY_LANGUAGE, "en_US");
await setSetting(worker, KEY_MIN_WORD_LENGTH_TO_PREDICT, 1);
await setSetting(worker, KEY_INLINE_SUGGESTION, inline);
await notifyConfigChange(browser, worker);
await gotoTestPage(page, options);
await page.bringToFront();
const editor = fixture ? await reviewEditorFixture(fixture) : null;
const selector = editor?.editable ?? fieldSelector;
const surface: Page | Frame = editor
? editor.surface
: surfaceKind === "tinymce"
? await waitUntil(
"TinyMCE editing frame",
async () => (await (await page.$("#test-tinymce_ifr"))?.contentFrame()) ?? false,
{ timeoutMs: INPUT_READY_TIMEOUT_MS },
)
: surfaceKind === "gutenberg"
? await waitUntil(
"editor frame",
async () =>
page
.frames()
.find(
(frame) =>
frame.name() === "editor-canvas" || frame.url().startsWith("blob:"),
) ?? false,
{ timeoutMs: INPUT_READY_TIMEOUT_MS },
)
: page;
await setup?.(surface);
await waitForInputReady(surface, selector);
// The Notion-like page drops a key that comes before it selects the block.
if (fixture === "notion") {
await clickNotionBlockEnd(1);
await page.keyboard.type(" ");
} else if (fixture) {
await placeCaretAfter(surface, selector, "dog.");
await page.keyboard.type(" ");
} else {
await surface.$eval(selector, (element) => {
(element as HTMLElement).focus();
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)
element.select();
else element.ownerDocument.getSelection()!.selectAllChildren(element);
});
await sleep(250);
await page.keyboard.press("Backspace");
}
await page.keyboard.type("Hello there world wo", { delay: 20 });
/** A suggestion menu or an inline ghost is on screen. */
const shown = async () =>
(await hasVisibleSuggestions(surface)) ||
(await surface.evaluate(() =>
Boolean(document.querySelector(".ft-suggestion-inline")?.textContent),
));
await waitUntil("suggestion after typing", shown, {
timeoutMs: SUGGESTION_TIMEOUT_MS,
});

/** Page coordinates of the middle of `needle` in the field. */
const pointIn = async (needle: string) => {
const point = await surface.$eval(
selector,
(element, text) => {
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
const index = element.value.indexOf(text);
const style = getComputedStyle(element);
const context = document.createElement("canvas").getContext("2d")!;
context.font = style.font;
const rect = element.getBoundingClientRect();
const x =
rect.left +
parseFloat(style.borderLeftWidth) +
parseFloat(style.paddingLeft) +
context.measureText(element.value.slice(0, index + text.length / 2)).width -
element.scrollLeft;
const line = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;
const y =
element instanceof HTMLInputElement
? rect.top + rect.height / 2
: rect.top +
parseFloat(style.borderTopWidth) +
parseFloat(style.paddingTop) +
line / 2;
return { x, y };
}
const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const index = node.textContent!.indexOf(text);
if (index < 0) continue;
const range = element.ownerDocument.createRange();
range.setStart(node, index + Math.floor(text.length / 2));
range.collapse(true);
const rect = range.getClientRects()[0] ?? range.getBoundingClientRect();
return { x: rect.left, y: rect.top + rect.height / 2 };
}
return null;
},
needle,
);
if (!point) throw new Error(`No text ${needle}`);
const offset =
surface === page ? null : await (await (surface as Frame).frameElement())?.boundingBox();
return { x: point.x + (offset?.x ?? 0), y: point.y + (offset?.y ?? 0) };
};
/** True when a menu shows within `ms` after `action`. */
const menuAfter = async (action: () => Promise<void>, ms = 1500) => {
await action();
const until = Date.now() + ms;
while (Date.now() < until) {
if (await shown()) return true;
await sleep(50);
}
return false;
};
const opened: string[] = [];
// A click into the text while the menu is open.
let point = await pointIn("there");
if (await menuAfter(() => page.mouse.click(point.x, point.y)))
opened.push("click while the menu is open");
// Arrow keys over existing text.
if (
await menuAfter(async () => {
for (const key of ["ArrowLeft", "ArrowLeft", "ArrowRight"] as const)
await page.keyboard.press(key);
})
)
opened.push("arrow keys");
// Leave the field, then click back into the middle of a word.
await page.mouse.click(5, 5);
await sleep(300);
point = await pointIn("Hello");
if (await menuAfter(() => page.mouse.click(point.x, point.y)))
opened.push("click into the field from outside");
// A second click in the field moves the caret again.
point = await pointIn("world");
if (await menuAfter(() => page.mouse.click(point.x, point.y)))
opened.push("second click in the field");
expect(opened).toEqual([]);
},
suiteTimeout(30000, 50000),
);

test(
"Gutenberg native slash menu keeps priority during block transformation",
async () => {
Expand Down
38 changes: 38 additions & 0 deletions tests/e2e/google-docs.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,44 @@ describe("Google Docs cross-world fixture (not live Docs)", () => {
expect((await model()).pastes).toBe(1);
expect((await evaluate<string[]>("events")).filter((v) => v === "accepted")).toHaveLength(1);
});
// Regression: a click (or an arrow key) that only moved the caret opened the menu. The
// dismiss forgot the snapshot, so the next poll read the moved caret as an edit.
test("a click or an arrow key that only moves the caret requests no suggestions", async () => {
await evaluate('predictions=["hello"]');
await page.keyboard.type("hello wor", { delay: TYPING_DELAY_MS });
const menuShown = () =>
evaluate<boolean>("document.querySelector('iframe').hasAttribute('data-ft-docs-key-state')");
await waitUntil("completion", menuShown);
const moveCaret = (caret: number) =>
page.evaluate((at) => {
const f = window as unknown as {
model: { text: string };
setModel: (text: string, a: number, f: number) => void;
};
f.setModel(f.model.text, at, at);
}, caret);
const requested = () => evaluate<number>("requests.length");
const before = await requested();
// A click: pointerdown in the input frame, then Docs moves the caret.
await page
.frames()[1]
.evaluate(() =>
document.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, composed: true })),
);
await moveCaret(2);
// Several 200ms polls.
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(await menuShown()).toBe(false);
expect(await requested()).toBe(before);
await page.keyboard.press("ArrowRight");
await moveCaret(3);
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(await menuShown()).toBe(false);
expect(await requested()).toBe(before);
// An edit still asks.
await page.keyboard.type("l", { delay: TYPING_DELAY_MS });
await waitUntil("a request after the edit", async () => (await requested()) > before);
});
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.
Expand Down
Loading