mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("/");
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
Reference in New Issue
Block a user