test(desktop): validate one-time DM mentions in editor prefix

Move the existing later-slice DM-loading fixture correction to the editor prefix. Assert no premature signing and exact final recipients without treating a one-time mention as an automatic address.

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-08 13:20:53 -04:00
parent b4a4f6f243
commit 75f61d7d47
5 changed files with 182 additions and 85 deletions

View File

@@ -563,3 +563,86 @@ test("full-name boundaries preserve internal spaces and intentional caret moves"
1 + "@Scout (ed12) ".length,
);
});
test("team separator survives qualified-label refresh and immediate typing", () => {
// Long enough that the boundary scan must include the wrapper and delimiter.
const label = `Remote Scout (${"b".repeat(64)})`;
const names = ["Remote Scout", label];
const inserted = `Scouts(@Remote Scout @${label}) `;
const edge = inserted.length;
const storage = { names: [], agentNames: [], channelNames: [] };
const plugins = MentionHighlightExtension.config.addProseMirrorPlugins.call({
storage,
});
const state = EditorState.create({
doc: document(paragraph(text("@Scouts"))),
schema,
plugins,
});
const tr = state.tr.insertText(inserted, 1, 8);
tr.setSelection(TextSelection.create(tr.doc, edge + 1));
settleAutocompleteMentionInsert(
{ storage: { mentionHighlight: storage } },
tr,
inserted,
);
const picked = state.apply(tr);
assignMentionHighlightNames(storage, names, [], []);
const refreshed = picked.apply(picked.tr.setMeta(mentionHighlightKey, true));
assert.equal(
selectionAfterMentionTrailingSpace(refreshed.doc, edge, names),
edge + 1,
);
const typed = textInput(refreshed, edge, edge + 1, "\u00a0h");
assert.equal(
typeAt(typed, typed.selection.from, "ello").doc.textContent,
`${inserted}hello`,
);
// Wrapper recognition must not consume arbitrary suffixes.
for (const suffix of ["", ")", "))", "x)"]) {
const doc = document(
paragraph(text(`Scouts(@Remote Scout @${label}${suffix} `)),
);
const pos = doc.textContent.length;
assert.equal(
selectionAfterMentionTrailingSpace(doc, pos, names),
pos + Number(suffix === "" || suffix === ")"),
);
}
});
// Registration is label/key intent; raw typing of another occurrence must not
// depend on Space autocomplete succeeding or a later names-prop refresh.
for (const name of ["Morgarita", "Remote Scout"]) {
test(`incremental typing decorates a second registered ${name} occurrence`, () => {
const prefix = `@${name} `;
const state = editorStateWithMentionHighlight(prefix, [name]);
const typed = typeAt(
state,
prefix.length + 1,
`@${name} authored duplicate`,
);
const decorations = mentionHighlightKey.getState(typed).find();
assert.equal(decorations.length, 4); // prefix + label for each occurrence
assert.equal(typed.doc.textContent, `${prefix}@${name} authored duplicate`);
assert.equal(typed.selection.from, typed.doc.textContent.length + 1);
assert.deepEqual(
decorations.map(({ from, to }) => typed.doc.textBetween(from, to)),
["@", name, "@", name],
);
});
}
test("typing an unregistered name cannot create mention decoration", () => {
const state = editorStateWithMentionHighlight("@Morgarita ", ["Morgarita"]);
const typed = typeAt(state, 12, "@Vogue authored text");
assert.equal(mentionHighlightKey.getState(typed).find().length, 2);
});
test("deleting an unrecognized suffix exposes the registered mention again", () => {
const state = editorStateWithMentionHighlight("@MorgaritaX", ["Morgarita"]);
assert.equal(mentionHighlightKey.getState(state).find().length, 0);
const next = state.apply(state.tr.delete(11, 12));
assert.equal(next.doc.textContent, "@Morgarita");
assert.equal(mentionHighlightKey.getState(next).find().length, 2);
});

View File

@@ -375,7 +375,7 @@ export const MentionHighlightExtension = Extension.create({
}
// Check if the edit touches a mention boundary. If the changed
// ranges contain `@` or `#` (either before or after the edit),
// textblocks contain `@` or `#` (before or after the edit),
// a mention may have been created, modified, or destroyed — do
// a full rebuild. Otherwise, just map existing decoration
// positions through the transaction mapping (cheap, no DOM churn).
@@ -611,7 +611,7 @@ export function selectionAfterMentionTrailingSpace(
// A multi-word name's internal spaces are not trailing separators.
const lookbehind = Math.min(
pos,
names.reduce((max, name) => Math.max(max, name.length + 2), 80),
names.reduce((max, name) => Math.max(max, name.length + 3), 80),
);
const before = doc.textBetween(pos - lookbehind, pos, "\n", "\0");
const lower = before.toLowerCase();
@@ -645,13 +645,18 @@ export function selectionAfterMentionTrailingSpace(
});
if (internalSpace) return pos;
const knownBoundary = names.some((name) => {
const start = before.length - name.length - 1;
return (
start >= 0 &&
(start === 0 || /[\s(]/.test(before[start - 1])) &&
(before[start] === "@" || before[start] === "#") &&
lower.slice(start + 1) === name.toLowerCase()
);
// Team expansion adds one closing parenthesis outside the literal label.
// Match it separately: refreshed registrations contain the exact member
// label, not the wrapper, including when the label itself ends in a key.
return [name.toLowerCase(), `${name.toLowerCase()})`].some((label) => {
const start = before.length - label.length - 1;
return (
start >= 0 &&
(start === 0 || /[\s(]/.test(before[start - 1])) &&
(before[start] === "@" || before[start] === "#") &&
lower.slice(start + 1) === label
);
});
});
if (!knownBoundary && !/(?:^|[\s(])[@#][^\s]+$/.test(before)) return pos;
return pos + 1;
@@ -679,64 +684,26 @@ export function findHighlightMatches(
}
/**
* Returns true if the transaction's changed ranges touch text that contains
* `@` or `#` — meaning a mention/channel-link boundary may have been
* created, modified, or destroyed and we need a full decoration rebuild.
*
* We check both the old content (in case a mention was deleted/split) and
* the new content (in case one was just typed). Uses a simple approach:
* iterate each step's changed ranges via the first stepMap (sufficient for
* the single-step transactions a chat composer produces on each keystroke).
* A changed textblock containing a trigger may gain or lose a literal match.
* Looking only at inserted/deleted characters misses incremental completion:
* the final "a" in a registered @Morgarita contains no @ and touches no chip.
* Check each step's own documents, including zero-width ranges after deletion.
*/
function editAffectsMentionBoundary(tr: Transaction): boolean {
const mentionChars = /[@#]/;
// For each step, check old and new text in the changed range.
// stepMap.forEach gives (oldFrom, oldTo, newFrom, newTo) where old
// positions are in the doc before that step and new positions are in
// the doc after that step.
const hasTrigger = (doc: ProseMirrorNode, from: number, to: number) => {
const start = doc.resolve(from).start();
const end = doc.resolve(to).end();
return /[@#]/.test(doc.textBetween(start, end, "\n", "\0"));
};
for (let i = 0; i < tr.steps.length; i++) {
const map = tr.mapping.maps[i];
let found = false;
map.forEach((oldFrom, oldTo, newFrom, newTo) => {
if (found) return;
// Check new doc text in the affected range
const clampedNewTo = Math.min(newTo, tr.doc.content.size);
const clampedNewFrom = Math.min(newFrom, clampedNewTo);
if (clampedNewFrom < clampedNewTo) {
const newText = tr.doc.textBetween(
clampedNewFrom,
clampedNewTo,
"\n",
"\0",
);
if (mentionChars.test(newText)) {
found = true;
return;
}
}
// Check old doc text in the affected range
const clampedOldTo = Math.min(oldTo, tr.before.content.size);
const clampedOldFrom = Math.min(oldFrom, clampedOldTo);
if (clampedOldFrom < clampedOldTo) {
const oldText = tr.before.textBetween(
clampedOldFrom,
clampedOldTo,
"\n",
"\0",
);
if (mentionChars.test(oldText)) {
found = true;
}
}
tr.mapping.maps[i].forEach((oldFrom, oldTo, newFrom, newTo) => {
found ||=
hasTrigger(tr.docs[i], oldFrom, oldTo) ||
hasTrigger(tr.docs[i + 1] ?? tr.doc, newFrom, newTo);
});
if (found) return true;
}
return false;
}

View File

@@ -394,7 +394,10 @@ export function useRichTextEditor({
addKeyboardShortcuts() {
return {
Enter: ({ editor: ed }) => {
if (isAutocompleteOpen?.current) return false;
// The wrapper owns autocomplete Enter. Suppress splitBlock
// before the event bubbles there, so its current-document
// check sees the query the user actually chose.
if (isAutocompleteOpen?.current) return true;
if (!onSubmitRef.current) return false;
const fenceResult = handleCodeFenceEnter(ed);

View File

@@ -60,6 +60,35 @@ function autocomplete(page: import("@playwright/test").Page) {
.getByTestId("mention-autocomplete");
}
async function waitForCompleteMentionSearch(
page: import("@playwright/test").Page,
query: string,
) {
await expect
.poll(() =>
page.evaluate((query) => {
const state = window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState([
"user-search",
"infinite",
query,
50,
]) as
| {
status: string;
fetchStatus: string;
data?: { pages: { nextCursor: string | null }[] };
}
| undefined;
return (
state?.status === "success" &&
state.fetchStatus === "idle" &&
state.data?.pages.at(-1)?.nextCursor === null
);
}, query),
)
.toBe(true);
}
async function readCommandLog(page: import("@playwright/test").Page) {
return page.evaluate(() => {
return (
@@ -856,14 +885,7 @@ test("defers agent mentions until DM members finish loading", async ({
const threadPanel = page.getByTestId("message-thread-panel");
const input = threadPanel.getByTestId("message-input");
await input.fill("Ask @ali");
await expect(
threadPanel
.getByTestId("mention-autocomplete")
.locator("button", { hasText: "alice" }),
).toBeVisible();
await input.press("Enter");
await page.keyboard.type(" before members resolve");
await input.fill("Ask @alice before members resolve");
const baselineCommands = await readCommandLog(page);
await threadPanel.getByTestId("send-message").click();
@@ -871,6 +893,9 @@ test("defers agent mentions until DM members finish loading", async ({
page.getByText(DM_THREAD_MEMBERS_LOADING_ERROR_TEXT).first(),
).toBeVisible();
await page.mouse.move(0, 0);
expect(commandCount(await readCommandLog(page), "sign_event")).toBe(
commandCount(baselineCommands, "sign_event"),
);
expect(commandCount(await readCommandLog(page), "add_channel_members")).toBe(
commandCount(baselineCommands, "add_channel_members"),
);
@@ -885,8 +910,12 @@ test("defers agent mentions until DM members finish loading", async ({
expect(commandCount(await readCommandLog(page), "add_channel_members")).toBe(
commandCount(baselineCommands, "add_channel_members"),
);
await expect(input).toHaveText("@alice ");
// A one-time mention does not opt into automatic addressing after send.
await expect(input).toHaveText("");
await expect(threadPanel).toContainText("before members resolve");
expect(
await readOutgoingMentionPubkeys(page, "Ask @alice before members resolve"),
).toEqual([TEST_IDENTITIES.alice.pubkey]);
});
test("autocomplete filters managed-agent suggestions as user types", async ({
@@ -3146,23 +3175,38 @@ test("inserting a mention preserves Shift+Enter newlines (regression: bug #2)",
await expect(input.locator("br")).toHaveCount(1);
});
test("keyboard navigation selects mention with Enter", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
for (const channel of ["general", "watercooler"]) {
test(`keyboard navigation selects mention with Enter in ${channel}`, async ({
page,
}) => {
await page.goto("/");
await page.getByTestId(`channel-${channel}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channel);
if (channel === "watercooler")
await page.getByRole("button", { name: "Start a new post..." }).click();
const input = page.getByTestId("message-input");
await input.fill("@bo");
const input = page.getByTestId("message-input");
await input.click();
await page.keyboard.type("@bo");
const dropdown = autocomplete(page);
await expect(dropdown.getByText("bob")).toBeVisible();
const dropdown = page.getByTestId("mention-autocomplete");
await expect(dropdown.getByText("bob")).toBeVisible();
// Press Enter to select the first (and only) suggestion
await input.press("Enter");
// Select deliberately after the current search settles; a visible row alone
// does not authorize implicit completion while more results may arrive.
await waitForCompleteMentionSearch(page, "bo");
const baselineCommands = await readCommandLog(page);
await input.press("ArrowDown");
await input.press("Enter");
// Should insert @bob and NOT send the message
await expect(input).toHaveText("@bob ");
});
// Should insert @bob and NOT send the message
await expect(input).toHaveText("@bob ");
await expect(input.locator("p")).toHaveCount(1);
expect(commandCount(await readCommandLog(page), "sign_event")).toBe(
commandCount(baselineCommands, "sign_event"),
);
});
}
test("Escape dismisses autocomplete dropdown", async ({ page }) => {
await page.goto("/");

View File

@@ -1365,8 +1365,8 @@ test("an authored duplicate leading mention survives draft restoration", async (
await openGeneral(page);
await expect(input).toHaveText("@Morgarita @Morgarita authored duplicate");
// Exact typed mentions now resolve on Space, so both the automatic prefix and
// the authored duplicate retain mention identity after restoration.
// Both occurrences use the already registered exact label. Decoration must
// not depend on raw Space completing a picker query or refreshing the names.
await expect(input.locator(".agent-mention-highlight")).toHaveCount(2);
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {