test(mentions): prove editor cancellation with native range restoration

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-09 16:46:52 -04:00
parent 0e1c83e830
commit 14c900a343
2 changed files with 256 additions and 157 deletions

View File

@@ -239,9 +239,12 @@ function Composer() {
{
onKeyDown: (event) => {
// Only editor events reach this bridge, as in MessageComposer.
if (event.key === "Tab" && event.shiftKey &&
event.target === richText.editor.view.dom &&
focusMentionOptionsTrigger(formRef.current)) {
if (
event.key === "Tab" &&
event.shiftKey &&
event.target === richText.editor.view.dom &&
focusMentionOptionsTrigger(formRef.current)
) {
event.preventDefault();
return;
}
@@ -258,7 +261,9 @@ function Composer() {
React.createElement(MentionAutocomplete, {
composerOwnsFocus: true,
keepMentionedAgentsPinned: keepPinned,
onKeepMentionedAgentsPinnedChange: state.withOptions ? setKeepPinned : undefined,
onKeepMentionedAgentsPinnedChange: state.withOptions
? setKeepPinned
: undefined,
isOpen: mention.isMentionOpen,
isLoading: mention.isMentionLoading,
suggestions: mention.suggestions,
@@ -495,19 +500,30 @@ for (const mode of ["Enter", "pin"]) {
test(`navigation: editor ShiftTab abandons ${mode} even after return`, async () => {
await setup({ owner: OTHER, visible: true, directoryVisible: true });
let resolve;
state.fresh = new Promise((r) => { resolve = r; });
state.fresh = new Promise((r) => {
resolve = r;
});
await act(async () => {
richText.editor.view.dom.focus();
choose(mode);
});
unchanged();
await act(async () => {
richText.editor.view.dom.dispatchEvent(new dom.window.KeyboardEvent("keydown", {
key: "Tab", shiftKey: true, bubbles: true, cancelable: true,
}));
richText.editor.view.dom.dispatchEvent(
new dom.window.KeyboardEvent("keydown", {
key: "Tab",
shiftKey: true,
bubbles: true,
cancelable: true,
}),
);
const trigger = document.querySelector("[data-mention-options-trigger]");
assert.ok(trigger, "chat Options trigger exists");
assert.equal(document.activeElement === trigger, true, "ShiftTab focuses Options");
assert.equal(
document.activeElement === trigger,
true,
"ShiftTab focuses Options",
);
richText.editor.view.dom.focus();
});
await act(async () => resolve([rawAgent()]));
@@ -521,65 +537,110 @@ for (const shiftKey of [false, true]) {
test(`navigation: focused pin ${shiftKey ? "ShiftTab" : "Tab"} depart=${depart}`, async () => {
await setup({ owner: OTHER, visible: true, directoryVisible: true });
let resolve;
state.fresh = new Promise((r) => { resolve = r; });
const toggle = document.querySelector(`[data-testid="mention-always-address-${AGENT}"]`);
state.fresh = new Promise((r) => {
resolve = r;
});
const toggle = document.querySelector(
`[data-testid="mention-always-address-${AGENT}"]`,
);
assert.ok(toggle, "production pin Toggle exists");
await act(async () => {
toggle.focus();
assert.equal(document.activeElement === toggle, true, "keyboard pin owns focus");
assert.equal(
document.activeElement === toggle,
true,
"keyboard pin owns focus",
);
const key = shiftKey ? " " : "Enter";
const event = new dom.window.KeyboardEvent("keydown", {
key, bubbles: true, cancelable: true,
key,
bubbles: true,
cancelable: true,
});
toggle.dispatchEvent(event);
assert.equal(event.defaultPrevented, false, "overlay activation stays native");
assert.equal(state.freshCalls ?? 0, 0, "pin keydown must not select in editor");
assert.equal(
event.defaultPrevented,
false,
"overlay activation stays native",
);
assert.equal(
state.freshCalls ?? 0,
0,
"pin keydown must not select in editor",
);
const release = new dom.window.KeyboardEvent("keyup", {
key, bubbles: true, cancelable: true,
key,
bubbles: true,
cancelable: true,
});
// JSDOM lacks native activation: Enter clicks on keydown, Space on keyup.
if (key === " ") toggle.dispatchEvent(release);
if (!event.defaultPrevented && !release.defaultPrevented) toggle.click();
if (!event.defaultPrevented && !release.defaultPrevented)
toggle.click();
});
unchanged();
assert.equal(state.freshCalls, 1);
if (depart) await act(async () => {
const event = new dom.window.KeyboardEvent("keydown", {
key: "Tab", shiftKey, bubbles: true, cancelable: true,
if (depart)
await act(async () => {
const event = new dom.window.KeyboardEvent("keydown", {
key: "Tab",
shiftKey,
bubbles: true,
cancelable: true,
});
toggle.dispatchEvent(event);
assert.equal(
event.defaultPrevented,
false,
"overlay Tab stays native",
);
const outside = document.createElement("button");
document.body.append(outside);
if (!event.defaultPrevented) outside.focus();
assert.equal(
document.activeElement === outside,
true,
"Tab departs pin",
);
toggle.focus();
});
toggle.dispatchEvent(event);
assert.equal(event.defaultPrevented, false, "overlay Tab stays native");
const outside = document.createElement("button");
document.body.append(outside);
if (!event.defaultPrevented) outside.focus();
assert.equal(document.activeElement === outside, true, "Tab departs pin");
toggle.focus();
});
await act(async () => resolve([rawAgent()]));
await settle();
if (depart) {
unchanged();
assert.equal(picker.announcement, "");
} else {
assert.deepEqual(effects.filter(([kind]) => kind === "promote"), [["promote", AGENT]]);
assert.deepEqual(effects.filter(([kind]) => kind === "autoPin"), []);
assert.deepEqual(effects.filter(([kind]) => kind === "provenance"), [
["provenance", [{ pubkey: AGENT, prefix: "@Remote Scout " }]],
]);
assert.deepEqual(
effects.filter(([kind]) => kind === "promote"),
[["promote", AGENT]],
);
assert.deepEqual(
effects.filter(([kind]) => kind === "autoPin"),
[],
);
assert.deepEqual(
effects.filter(([kind]) => kind === "provenance"),
[["provenance", [{ pubkey: AGENT, prefix: "@Remote Scout " }]]],
);
assert.equal(effects.filter(([kind]) => kind === "edit").length, 2);
assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []);
assert.equal(richText.getPlainTextAndCursor().text, "@Remote Scout ");
assert.equal(mention.getDraftMentionRefs("@Remote Scout ")[0]?.pubkey, AGENT);
assert.equal(
mention.getDraftMentionRefs("@Remote Scout ")[0]?.pubkey,
AGENT,
);
// MentionHighlightExtension decorates literal @labels from either path;
// .mention-chip is not selection provenance. The pin witnesses above
// distinguish admission paths; verify the prefix is plain document text
// (useRichTextEditor.replacePlainTextRange), not an embedded mention node.
assert.deepEqual(richText.editor.getJSON(), {
type: "doc",
content: [{
type: "paragraph",
content: [{ type: "text", text: "@Remote Scout " }],
}],
content: [
{
type: "paragraph",
content: [{ type: "text", text: "@Remote Scout " }],
},
],
});
}
});
@@ -588,10 +649,16 @@ for (const shiftKey of [false, true]) {
for (const departure of ["window", "no-Options native fallback"]) {
test(`navigation: ${departure} abandons pending selection after return`, async () => {
await setup({ owner: OTHER, visible: true, directoryVisible: true,
withOptions: departure !== "no-Options native fallback" });
await setup({
owner: OTHER,
visible: true,
directoryVisible: true,
withOptions: departure !== "no-Options native fallback",
});
let resolve;
state.fresh = new Promise((r) => { resolve = r; });
state.fresh = new Promise((r) => {
resolve = r;
});
await act(async () => {
richText.editor.view.dom.focus();
choose("Enter");
@@ -603,20 +670,38 @@ for (const departure of ["window", "no-Options native fallback"]) {
if (departure === "window") {
// Window departure can retain activeElement; dispatch only that boundary.
dom.window.dispatchEvent(new dom.window.Event("blur"));
assert.equal(document.activeElement === editor, true, "window blur retains editor identity");
assert.equal(
document.activeElement === editor,
true,
"window blur retains editor identity",
);
dom.window.dispatchEvent(new dom.window.Event("focus"));
} else {
assert.equal(document.querySelector("[data-mention-options-trigger]") === null, true);
assert.equal(
document.querySelector("[data-mention-options-trigger]") === null,
true,
);
const event = new dom.window.KeyboardEvent("keydown", {
key: "Tab", shiftKey: true, bubbles: true, cancelable: true,
key: "Tab",
shiftKey: true,
bubbles: true,
cancelable: true,
});
editor.dispatchEvent(event);
assert.equal(event.defaultPrevented, false, "no Options leaves ShiftTab native");
assert.equal(
event.defaultPrevented,
false,
"no Options leaves ShiftTab native",
);
// Explicit native Tab default emulation, not a browser tab-order claim.
const outside = document.createElement("button");
document.body.append(outside);
if (!event.defaultPrevented) outside.focus();
assert.equal(document.activeElement === outside, true, "native fallback departs editor");
assert.equal(
document.activeElement === outside,
true,
"native fallback departs editor",
);
editor.focus();
}
});

View File

@@ -2343,9 +2343,7 @@ test("navigating to mention Options during a held selection inserts nothing late
await expect(input).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(
page
.getByTestId("message-composer")
.getByTestId("mention-options-trigger"),
page.getByTestId("message-composer").getByTestId("mention-options-trigger"),
).toBeFocused();
// Release the held authority response and settle the downstream DOM.
@@ -2437,9 +2435,9 @@ test("navigating away from the mention row pin during its held revalidation pins
await holdMentionGateCommand(page, "revalidate_relay_agents");
await page.keyboard.press("Enter");
await waitForMentionGate(page);
await expect(
page.getByTestId("composer-address-lock-status"),
).toContainText("Checking access");
await expect(page.getByTestId("composer-address-lock-status")).toContainText(
"Checking access",
);
// Native Shift+Tab from the pin — the editor's key handler sees no overlay
// events — must really move focus off the pin control.
@@ -2510,120 +2508,136 @@ test("navigating away from the mention row pin during its held revalidation pins
`composer-address-lock-${ALLOWLIST_RELAY_AGENT_PUBKEY}`,
),
).toBeVisible();
await expect(
page.getByTestId("composer-address-lock-status"),
).toContainText("Automatically mentioning quinn");
await expect(page.getByTestId("composer-address-lock-status")).toContainText(
"Automatically mentioning quinn",
);
await expect(input).toHaveText("@quinn ");
await expect(input.locator(".agent-mention-highlight")).toHaveText("quinn");
});
test("editing and restoring the draft during a held mention selection inserts nothing late", async ({
page,
}) => {
await installMockBridge(page, {
relayAgents: [
{
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
name: "quinn",
respondTo: "allowlist",
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
channelNames: ["general"],
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const composer = page.getByTestId("message-composer");
const input = page.getByTestId("message-input");
// Read-only DOM offsets: no editor state or browser selection is changed.
const readCaret = () =>
input.evaluate((element) => {
const selection = window.getSelection();
if (
!selection?.anchorNode ||
!selection.focusNode ||
!element.contains(selection.anchorNode) ||
!element.contains(selection.focusNode)
) {
return null;
}
const offset = (node: Node, position: number) => {
const range = document.createRange();
range.selectNodeContents(element);
range.setEnd(node, position);
return range.toString().length;
};
return {
anchor: offset(selection.anchorNode, selection.anchorOffset),
focus: offset(selection.focusNode, selection.focusOffset),
collapsed: selection.isCollapsed,
};
for (const change of ["draft", "selection range"] as const) {
test(`editing and restoring the ${change} during a held mention selection inserts nothing late`, async ({
page,
}) => {
await installMockBridge(page, {
relayAgents: [
{
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
name: "quinn",
respondTo: "allowlist",
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
channelNames: ["general"],
},
],
});
await input.fill("@quinn");
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
await expect(quinnRow).toBeVisible();
await expect(quinnRow).toBeEnabled();
await expect(input).toBeFocused();
const originalCaret = await readCaret();
expect(originalCaret).toEqual({ anchor: 6, focus: 6, collapsed: true });
const baselineCommands = await readCommandLog(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await holdMentionGateCommand(page, "revalidate_relay_agents");
await quinnRow.click();
await waitForMentionGate(page);
const status = page.getByTestId("composer-address-lock-status");
await expect(status).toContainText("Checking access");
await expect(input).toBeFocused();
await expect.poll(readCaret).toEqual(originalCaret);
const composer = page.getByTestId("message-composer");
const input = page.getByTestId("message-input");
// Read-only DOM offsets: no editor state or browser selection is changed.
const readCaret = () =>
input.evaluate((element) => {
const selection = window.getSelection();
if (
!selection?.anchorNode ||
!selection.focusNode ||
!element.contains(selection.anchorNode) ||
!element.contains(selection.focusNode)
) {
return null;
}
const offset = (node: Node, position: number) => {
const range = document.createRange();
range.selectNodeContents(element);
range.setEnd(node, position);
return range.toString().length;
};
return {
anchor: offset(selection.anchorNode, selection.anchorOffset),
focus: offset(selection.focusNode, selection.focusOffset),
collapsed: selection.isCollapsed,
};
});
await input.fill("@quinn");
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
await expect(quinnRow).toBeVisible();
await expect(quinnRow).toBeEnabled();
await expect(input).toBeFocused();
const originalCaret = await readCaret();
expect(originalCaret).toEqual({ anchor: 6, focus: 6, collapsed: true });
const baselineCommands = await readCommandLog(page);
// Real edits invalidate the pending operation even when text AND caret
// return to their original values before the allowed response arrives.
await page.keyboard.press("Backspace");
await expect(input).toHaveText("@quin");
await expect(input).toBeFocused();
await page.keyboard.type("n");
await expect(input).toHaveText("@quinn");
await expect(input).toBeFocused();
await expect.poll(readCaret).toEqual(originalCaret);
await holdMentionGateCommand(page, "revalidate_relay_agents");
await quinnRow.click();
await waitForMentionGate(page);
const status = page.getByTestId("composer-address-lock-status");
await expect(status).toContainText("Checking access");
await expect(input).toBeFocused();
await expect.poll(readCaret).toEqual(originalCaret);
await releaseMentionGate(page);
await expect
.poll(async () =>
commandCount(await readCommandLog(page), "revalidate_relay_agents"),
)
.toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1);
// Same downstream settlement window as the adjacent held-gate probes.
await page.waitForTimeout(300);
if (change === "draft") {
// Behavior coverage: text edits also invalidate the query revision.
await page.keyboard.press("Backspace");
await expect(input).toHaveText("@quin");
await expect(input).toBeFocused();
await page.keyboard.type("n");
} else {
// Query snapshots use the selection ANCHOR, not its moving head.
// Extend and retract a real native range without changing text/anchor:
// query revision cannot mask removal of editor cancellation listeners.
await page.keyboard.press("Shift+ArrowLeft");
await expect(input).toHaveText("@quinn");
await expect(input).toBeFocused();
await expect.poll(readCaret).toEqual({
anchor: 6,
focus: 5,
collapsed: false,
});
await page.keyboard.press("Shift+ArrowRight");
}
await expect(input).toHaveText("@quinn");
await expect(input).toBeFocused();
await expect.poll(readCaret).toEqual(originalCaret);
await expect(input).toBeFocused();
await expect(input).toHaveText("@quinn");
await expect.poll(readCaret).toEqual(originalCaret);
await expect(input.locator(".mention-chip")).toHaveCount(0);
await expect(input.locator(".agent-mention-highlight")).toHaveCount(0);
await expect(
composer.getByTestId(
`composer-address-lock-${ALLOWLIST_RELAY_AGENT_PUBKEY}`,
),
).toHaveCount(0);
await expect(status).not.toContainText("Checking access");
await expect(status).not.toContainText("Automatically mentioning");
const settledCommands = await readCommandLog(page);
for (const command of [
"add_channel_members",
"start_managed_agent",
"attach_managed_agent",
"sync_agents_to_active_huddle",
"send_channel_message",
"sign_event",
]) {
expect(commandCount(settledCommands, command)).toBe(
commandCount(baselineCommands, command),
);
}
expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull();
});
await releaseMentionGate(page);
await expect
.poll(async () =>
commandCount(await readCommandLog(page), "revalidate_relay_agents"),
)
.toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1);
// Same downstream settlement window as the adjacent held-gate probes.
await page.waitForTimeout(300);
await expect(input).toBeFocused();
await expect(input).toHaveText("@quinn");
await expect.poll(readCaret).toEqual(originalCaret);
await expect(input.locator(".mention-chip")).toHaveCount(0);
await expect(input.locator(".agent-mention-highlight")).toHaveCount(0);
await expect(
composer.getByTestId(
`composer-address-lock-${ALLOWLIST_RELAY_AGENT_PUBKEY}`,
),
).toHaveCount(0);
await expect(status).not.toContainText("Checking access");
await expect(status).not.toContainText("Automatically mentioning");
const settledCommands = await readCommandLog(page);
for (const command of [
"add_channel_members",
"start_managed_agent",
"attach_managed_agent",
"sync_agents_to_active_huddle",
"send_channel_message",
"sign_event",
]) {
expect(commandCount(settledCommands, command)).toBe(
commandCount(baselineCommands, command),
);
}
expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull();
});
}
test("selected relay agents are invited as bots before sending", async ({
page,