mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
🤖 ## Summary Every Buzz account is identified by a long public key. Before this change, when someone had no display name, surfaces fell back to inconsistent labels — mostly raw hex fragments like `abcd1234…wxyz`, sometimes a generic role label with no key — so the same person looked different from surface to surface, and nothing looked like an npub address. This PR applies the npub identity foundation from #7488 to the everyday surfaces: a person without a display name now falls back to the same compact npub everywhere — `npub1xxxx…yyyy`, the human-readable spelling of their public key (first 8 + last 4 characters of the full npub) — across messages and mentions, reactions, huddles, member and participant lists, the sidebar and channel activity, search, projects, tray, notifications, and workflow surfaces. - **Mentions and messages**: key-only mention chips render the compact npub. Pasting a copied mention back still re-binds it byte-exactly to the identity it declares, for both the new npub chips and legacy hex-truncated chips copied by older clients — wrong, missing, or tampered key qualification is rejected instead of silently degrading to plain text. - **Reactions and huddles**: huddle reaction events and the huddle roster/participants render the compact npub for unnamed participants; workflow reaction triggers describe authors with the same form. - **Members and sidebar**: channel and community member lists, add-member results and invites, the members sidebar, the channel-activity popover, search, projects (assignees/reviewers/PR panels), the tray menu, and desktop notifications all fall back to the compact npub; titles and aria labels keep the machine-readable full labels. - **Profile labels**: panel/popover display names and owner handles fall back to the compact npub (never raw hex) when there is no name; linked-event (nevent) message metadata shows the npub-shaped author fallback while the event lookup and event IDs are unchanged. - **Workflows**: author-picker secondary labels, step destination keys, and trigger-author references render compact npubs; event and blob IDs keep their existing hex compacts (they are not identities). - **Avatars stay distinct**: fallback avatars for key-only identities derive initials from the key's tail, so prefixed role labels like "Participant npub1…" no longer collapse every unnamed participant onto the same initials; people with names keep their name initials. Preserved exactly: display names and distinct avatars, internal hex keys (storage/API forms unchanged), clipboard identity roundtrips, event/blob ID compaction, private keys (no nsec path is touched), and nevent link handling. Scope: this PR changes what identity labels **display**, not identity controls — profile/settings copy controls, the respond-to allowlist, workflow key fields, and agent dialogs are the sibling slice #7489, and the shared primitives (`canonicalNpub`, `truncateNpub`, the `<PubKey>` gate, strict input parsing) come from the foundation #7488. ### Related issue - Fixes: N/A. Searched existing issues/PRs for duplicates — none found; the related work is the npub identity stack this slice belongs to. - Base/dependency: stacks on #7488 (foundation) — this PR does not stand alone on main. - #7489 is a sibling slice on the same #7488 base (profile/agent/workflow controls), not a dependency: this PR does not require #7489, and #7489 does not require this PR — both only require #7488. ### Testing At exact head `4763cbeae1dd521309755e6d61f657324cb98667` (base: `fix/desktop-npub-identity-d1a` @ `5f3a4a8111998c8aa41ad77cf66992bd1c85343c`; 71 files, +656/−189 — production +277/−136, test support +379/−53): - At this head: targeted `mentions.spec.ts` (1/1), the e2e build, typecheck, and biome — green. - 9 changed/related unit files: 100/100 green; typecheck, e2e build, biome, and px text/truncation checks clean; huddle-roster focused run green; channel-activity e2e 11/11; mutation checks confirm the fallback wiring (removing it collapses shared initials and drops fallback rows). - Known pre-existing local e2e failures, unchanged by this PR and reproduced identically at the upstream merge-base: huddle-transcription voice-menu attribution (25 pass / 1 fail) and the `workflow-local-controls` 438px caret drift. Not claimed green locally. - Update at head `236af9e6137386737e84d3a474d6bc808a704c50` (test-only follow-ups `1143af345` + `236af9e6`): the `workflow-local-controls` races were fixed in the test drivers, and the 438px diff was shown to be a stale Darwin snapshot baseline (name-row enable switch already absent and `message_posted` already MessageSquare at recording commit `9390e11c9`) and refreshed — the focused screenshot test, including keyboard/caret assertions, now passes locally (twice). The full spec was not rerun after the snapshot refresh; the huddle-transcription item above is unchanged. Label/copy text changes are asserted by the e2e specs (`mentions`, `mention-recipients`, `pubkey-display-screenshots`, `huddle-transcription`, `channel-activity-popover`, `workflow-local-controls`) rather than new screenshots; the screenshot spec pins the compact npub text forms. ### Task provenance Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9` Task: buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6 --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
1046 lines
39 KiB
TypeScript
1046 lines
39 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import { truncateNpub } from "../../src/shared/lib/pubkey";
|
|
import { waitForAnimations } from "../helpers/animations";
|
|
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
|
|
|
const FIRST = TEST_IDENTITIES.alice.pubkey;
|
|
const SECOND = TEST_IDENTITIES.bob.pubkey;
|
|
const AMBIGUOUS =
|
|
"The mention @Scout is ambiguous. Choose a recipient from the mention picker.";
|
|
|
|
async function install(page: Page, channel = "general", agents = false) {
|
|
await installMockBridge(page, {
|
|
managedAgents:
|
|
channel === "watercooler"
|
|
? ["a".repeat(64), "b".repeat(64)].map((pubkey) => ({
|
|
pubkey,
|
|
name: "Scout",
|
|
status: "running",
|
|
channelNames: ["watercooler"],
|
|
}))
|
|
: agents
|
|
? [FIRST, SECOND].map((pubkey) => ({
|
|
pubkey,
|
|
name: "Scout",
|
|
status: "running",
|
|
channelNames: [channel],
|
|
}))
|
|
: [],
|
|
searchProfiles: [FIRST, SECOND].map((pubkey) => ({
|
|
pubkey,
|
|
displayName: "Scout",
|
|
isAgent: agents,
|
|
})),
|
|
});
|
|
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();
|
|
}
|
|
|
|
async function recipients(page: Page, content: string) {
|
|
return page.evaluate((content) => {
|
|
const signed = (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? [])
|
|
.filter((event) => event.content === content)
|
|
.map((event) =>
|
|
event.tags.filter((tag) => tag[0] === "p").map((tag) => tag[1]),
|
|
);
|
|
if (signed.length > 0) return signed;
|
|
// Thread sends use native IPC in the mock bridge, not signed capture.
|
|
return (window.__BUZZ_E2E_COMMAND_LOG__ ?? [])
|
|
.filter((call) => call.command === "send_channel_message")
|
|
.map(
|
|
(call) =>
|
|
call.payload as { content?: string; mentionPubkeys?: string[] },
|
|
)
|
|
.filter((payload) => payload.content === content)
|
|
.map((payload) => payload.mentionPubkeys ?? []);
|
|
}, content);
|
|
}
|
|
|
|
for (const channel of ["general", "watercooler"]) {
|
|
test(`ambiguous typed name is visible and preserves ${channel === "general" ? "chat" : "standalone forum"} draft`, async ({
|
|
page,
|
|
}) => {
|
|
await install(page, channel);
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@Scout hello");
|
|
await input.press("Escape");
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByText(AMBIGUOUS, { exact: false })).toBeVisible();
|
|
await expect(input).toHaveText("@Scout hello");
|
|
expect(await recipients(page, "@Scout hello")).toEqual([]);
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
path: `test-results/mention-recipients/ambiguous-${channel}.png`,
|
|
});
|
|
});
|
|
}
|
|
|
|
test("two selected same-name members send both exact identities", async ({
|
|
page,
|
|
}) => {
|
|
await install(page);
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@Scout");
|
|
await page.getByTestId(`mention-suggestion-${FIRST}`).click();
|
|
await page.keyboard.type("and @Scout");
|
|
await page.getByTestId(`mention-suggestion-${SECOND}`).click();
|
|
await page.keyboard.type("hello");
|
|
const content = `@Scout and @Scout (${SECOND}) hello`;
|
|
await expect(input).toHaveText(content);
|
|
await page.getByTestId("send-message").click();
|
|
await expect.poll(() => recipients(page, content)).toEqual([[FIRST, SECOND]]);
|
|
});
|
|
|
|
test("ambiguous added mention blocks editing before clearing the draft", async ({
|
|
page,
|
|
}) => {
|
|
await install(page);
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("original message for ambiguity edit");
|
|
await input.press("Enter");
|
|
const row = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await expect(row).toContainText("original message for ambiguity edit");
|
|
await row.hover();
|
|
await row.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
await expect(page.getByTestId("edit-target")).toBeVisible();
|
|
await expect(input).toHaveText("original message for ambiguity edit");
|
|
await input.fill("edited @Scout hello");
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByText(AMBIGUOUS, { exact: false })).toBeVisible();
|
|
await expect(input).toHaveText("edited @Scout hello");
|
|
await expect(page.getByTestId("edit-target")).toBeVisible();
|
|
expect(await recipients(page, "edited @Scout hello")).toEqual([]);
|
|
});
|
|
|
|
test("same-name teammates unfurl into distinct exact-key recipients", async ({
|
|
page,
|
|
}) => {
|
|
const pubkeys = ["a".repeat(64), "b".repeat(64)];
|
|
await installMockBridge(page, {
|
|
personas: pubkeys.map((_, i) => ({
|
|
id: `scout-${i}`,
|
|
displayName: "Scout",
|
|
systemPrompt: "Help.",
|
|
})),
|
|
managedAgents: pubkeys.map((pubkey, i) => ({
|
|
pubkey,
|
|
personaId: `scout-${i}`,
|
|
name: "Scout",
|
|
status: "running",
|
|
channelNames: ["general"],
|
|
})),
|
|
teams: [
|
|
{ id: "scouts", name: "Scouts", personaIds: ["scout-0", "scout-1"] },
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@Scouts");
|
|
await page.getByTestId("mention-suggestion-team-scouts").click();
|
|
await page.keyboard.type("hello");
|
|
const content = `Scouts(@Scout @Scout (${pubkeys[1]})) hello`;
|
|
await expect(input).toHaveText(content);
|
|
await page.getByTestId("send-message").click();
|
|
await expect.poll(() => recipients(page, content)).toEqual([pubkeys]);
|
|
});
|
|
|
|
for (const removal of ["delete", "audience-remove", "audience-unpin"]) {
|
|
test(`same-name automatic recipients: ${removal} A preserves exact remaining recipients`, async ({
|
|
page,
|
|
}) => {
|
|
const [a, b] = ["a".repeat(64), "b".repeat(64)];
|
|
await page.addInitScript(() =>
|
|
localStorage.setItem("buzz.messages.keepMentionedAgentsPinned", "true"),
|
|
);
|
|
await installMockBridge(page, {
|
|
managedAgents: [a, b].map((pubkey) => ({
|
|
pubkey,
|
|
name: "Scout",
|
|
status: "running",
|
|
channelNames: ["general"],
|
|
})),
|
|
});
|
|
// Main retains automatic audiences only in threads, never root posts.
|
|
await page.goto(
|
|
"/#/channels/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50?messageId=mock-general-welcome&thread=mock-general-welcome",
|
|
);
|
|
const composer = page.getByTestId("thread-composer-overlay");
|
|
await expect(composer).toBeVisible();
|
|
const input = composer.getByTestId("message-input");
|
|
await input.fill("@Scout");
|
|
await composer.getByTestId(`mention-suggestion-${a}`).click();
|
|
await page.keyboard.type("@Scout");
|
|
await composer.getByTestId(`mention-suggestion-${b}`).click();
|
|
await page.keyboard.type("hello");
|
|
await expect(input).toHaveText(`@Scout @Scout (${b}) hello`);
|
|
await expect(
|
|
composer.getByTestId(`composer-address-lock-${a}`),
|
|
).toBeVisible();
|
|
await expect(
|
|
composer.getByTestId(`composer-address-lock-${b}`),
|
|
).toBeVisible();
|
|
if (removal === "delete") {
|
|
// Select the literal prefix through the browser DOM, then use the real
|
|
// editor delete path. Do not replace draft state or mock the composer.
|
|
await input.evaluate((element) => {
|
|
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
const range = document.createRange();
|
|
let remaining = "@Scout ".length;
|
|
let node = walker.nextNode();
|
|
if (!node) throw new Error("Missing editor text");
|
|
range.setStart(node, 0);
|
|
while (node) {
|
|
const length = node.textContent?.length ?? 0;
|
|
if (remaining <= length) {
|
|
range.setEnd(node, remaining);
|
|
break;
|
|
}
|
|
remaining -= length;
|
|
node = walker.nextNode();
|
|
}
|
|
const selection = window.getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
element.dispatchEvent(new Event("focus"));
|
|
});
|
|
await input.press("Backspace");
|
|
} else if (removal === "audience-remove") {
|
|
await composer.getByTestId(`composer-address-lock-remove-${a}`).click();
|
|
} else {
|
|
await composer.locator("[data-mention-picker-trigger]").click();
|
|
await composer.getByTestId(`mention-always-address-${a}`).click();
|
|
await input.press("Escape");
|
|
}
|
|
// The tray unpins without deleting an authored mention; removal deletes it.
|
|
const content = `${removal === "audience-unpin" ? "@Scout " : ""}@Scout (${b}) hello`;
|
|
await expect(input).toHaveText(content);
|
|
await expect(
|
|
composer.getByTestId(`composer-address-lock-${a}`),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
composer.getByTestId(`composer-address-lock-${b}`),
|
|
).toBeVisible();
|
|
await composer.getByTestId("send-message").click();
|
|
await expect
|
|
.poll(() => recipients(page, content))
|
|
.toEqual([removal === "audience-unpin" ? [a, b] : [b]]);
|
|
});
|
|
}
|
|
|
|
test("selected duplicate labels survive send, reopen, replacement and second reopen", async ({
|
|
page,
|
|
}) => {
|
|
await install(page);
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@Scout");
|
|
await page.getByTestId(`mention-suggestion-${FIRST}`).click();
|
|
await page.keyboard.type("@Scout");
|
|
await page.getByTestId(`mention-suggestion-${SECOND}`).click();
|
|
await page.keyboard.type("roundtrip");
|
|
const original = `@Scout @Scout (${SECOND}) roundtrip`;
|
|
await page.getByTestId("send-message").click();
|
|
await expect
|
|
.poll(() => recipients(page, original))
|
|
.toEqual([[FIRST, SECOND]]);
|
|
const row = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
const openEdit = async () => {
|
|
await waitForAnimations(page);
|
|
await row.hover();
|
|
await row.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
};
|
|
await openEdit();
|
|
await expect(input).toHaveText(original);
|
|
await input.evaluate((element) => {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(element);
|
|
range.collapse(false);
|
|
window.getSelection()?.removeAllRanges();
|
|
window.getSelection()?.addRange(range);
|
|
element.focus();
|
|
});
|
|
await expect(input).toBeFocused();
|
|
await expect
|
|
.poll(() =>
|
|
input.evaluate((element) => {
|
|
const selection = window.getSelection();
|
|
return selection?.isCollapsed && element.contains(selection.anchorNode);
|
|
}),
|
|
)
|
|
.toBe(true);
|
|
await page.keyboard.type(" edited");
|
|
const replacement = `${original} edited`;
|
|
await expect(input).toHaveText(replacement);
|
|
await page.getByTestId("send-message").click();
|
|
// The mock bridge's edit_message path emits a mock event, not a signed-event
|
|
// capture. Assert the real composer's native command payload and reopened UI.
|
|
const editPayload = (content: string) =>
|
|
page.evaluate((content) => {
|
|
const call = window.__BUZZ_E2E_COMMAND_LOG__
|
|
?.filter((call) => call.command === "edit_message")
|
|
.at(-1);
|
|
const input = (
|
|
call?.payload as {
|
|
input?: {
|
|
content: string;
|
|
mentionTags: string[][];
|
|
mentionPubkeys: string[];
|
|
};
|
|
}
|
|
)?.input;
|
|
return input?.content === content
|
|
? {
|
|
references: input.mentionTags.map((t) => t[1]).sort(),
|
|
notifying: input.mentionPubkeys,
|
|
}
|
|
: null;
|
|
}, content);
|
|
await expect
|
|
.poll(() => editPayload(replacement))
|
|
.toEqual({ references: [FIRST, SECOND].sort(), notifying: [] });
|
|
await expect(row).toContainText("roundtrip edited");
|
|
await openEdit();
|
|
await expect(input).toHaveText(replacement);
|
|
// Delete the unqualified A, leaving the qualified B binding on the next edit.
|
|
const secondReplacement = `@Scout (${SECOND}) roundtrip edited twice`;
|
|
await input.fill(secondReplacement);
|
|
await page.getByTestId("send-message").click();
|
|
await expect
|
|
.poll(() => editPayload(secondReplacement))
|
|
.toEqual({ references: [SECOND], notifying: [] });
|
|
});
|
|
|
|
test("edit focus transfers after menu exit; Escape still restores the trigger", async ({
|
|
page,
|
|
}) => {
|
|
await install(page);
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("menu focus handoff");
|
|
await input.press("Enter");
|
|
const row = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await expect(row).toContainText("menu focus handoff");
|
|
await row.hover();
|
|
const trigger = row.getByRole("button", { name: "More actions" });
|
|
await trigger.click();
|
|
await page.getByRole("menu").press("Escape");
|
|
await expect(trigger).toBeFocused();
|
|
|
|
// Hold the real Radix exit lifecycle, rather than sleeping until the race
|
|
// happens to pass. No composer state or focus handlers are mocked.
|
|
await page.addStyleTag({
|
|
content: `@keyframes held-menu-exit { from { opacity: 1; } to { opacity: 0; } }
|
|
[data-radix-menu-content][data-state="closed"] {
|
|
animation: held-menu-exit 1s linear paused !important;
|
|
}`,
|
|
});
|
|
await trigger.click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
const closingMenu = page.locator(
|
|
'[data-radix-menu-content][data-state="closed"]',
|
|
);
|
|
await expect(closingMenu).toHaveCount(1);
|
|
await expect(page.getByTestId("edit-target")).toHaveCount(0);
|
|
await expect(input).toHaveText("");
|
|
// Leave the closing menu while its exit is held. Radix can still process
|
|
// pointer-leave here; it must not own focus after the edit handoff.
|
|
await input.hover();
|
|
await expect(page.getByTestId("edit-target")).toHaveCount(0);
|
|
await closingMenu.evaluate((element) => {
|
|
const animations = element.getAnimations();
|
|
if (!animations.length)
|
|
throw new Error("Expected held menu exit animation");
|
|
for (const animation of animations) animation.finish();
|
|
});
|
|
await expect(closingMenu).toHaveCount(0);
|
|
await expect(input).toHaveText("menu focus handoff");
|
|
await expect(input).toBeFocused();
|
|
await page.keyboard.type(" edited");
|
|
await expect(input).toHaveText("menu focus handoff edited");
|
|
});
|
|
|
|
test("editing to a longer typed member drops the original shorter reference", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
searchProfiles: [
|
|
{ pubkey: FIRST, displayName: "Scout" },
|
|
{ pubkey: SECOND, displayName: "Scout Jones" },
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@Scout");
|
|
await page.getByTestId(`mention-suggestion-${FIRST}`).click();
|
|
await page.keyboard.type("hello");
|
|
await page.getByTestId("send-message").click();
|
|
await expect.poll(() => recipients(page, "@Scout hello")).toEqual([[FIRST]]);
|
|
const row = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await expect(row).toContainText("Scout hello");
|
|
await waitForAnimations(page);
|
|
await row.hover();
|
|
await row.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
await expect(input).toHaveText("@Scout hello");
|
|
await input.fill("@Scout Jones hello");
|
|
await page.getByTestId("send-message").click();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const call = window.__BUZZ_E2E_COMMAND_LOG__
|
|
?.filter((call) => call.command === "edit_message")
|
|
.at(-1);
|
|
const input = (
|
|
call?.payload as {
|
|
input?: {
|
|
content: string;
|
|
mentionTags?: string[][];
|
|
mentionPubkeys: string[];
|
|
};
|
|
}
|
|
)?.input;
|
|
return input?.content === "@Scout Jones hello"
|
|
? {
|
|
references: input.mentionTags ?? [],
|
|
notifying: input.mentionPubkeys,
|
|
}
|
|
: null;
|
|
}),
|
|
)
|
|
.toEqual({ references: [["mention", SECOND]], notifying: [SECOND] });
|
|
await expect(row).toContainText("Scout Jones hello");
|
|
});
|
|
|
|
// The default relay directory knows FIRST as an agent; SECOND is a human.
|
|
// The agent variant makes SECOND managed too, covering a qualified bot label.
|
|
for (const { kind, scale } of [
|
|
{ kind: "mixed", scale: 1 },
|
|
{ kind: "mixed", scale: 1.5 },
|
|
{ kind: "agent", scale: 1 },
|
|
{ kind: "agent", scale: 1.5 },
|
|
]) {
|
|
test(`exact-key ${kind} chips wrap in narrow composer, sent message and reopen at ${scale}x text`, async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 800, height: 900 });
|
|
await install(page, "general", kind === "agent");
|
|
await page.evaluate((scale) => {
|
|
document.documentElement.style.fontSize = `${16 * scale}px`;
|
|
}, scale);
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@Scout");
|
|
await page.getByTestId(`mention-suggestion-${FIRST}`).click();
|
|
await page.keyboard.type("@Scout");
|
|
await page.getByTestId(`mention-suggestion-${SECOND}`).click();
|
|
await page.keyboard.type("layout journey");
|
|
const content = `@Scout @Scout (${SECOND}) layout journey`;
|
|
const geometry = async (host: import("@playwright/test").Locator) =>
|
|
host.evaluate((element) => {
|
|
const bounds = element.getBoundingClientRect();
|
|
const chips = [...element.querySelectorAll(".mention-chip")];
|
|
return {
|
|
width: bounds.width,
|
|
scrollWidth: element.scrollWidth,
|
|
clientWidth: element.clientWidth,
|
|
chips: chips.map((chip) => ({
|
|
text: chip.textContent,
|
|
literalKey: chip.classList.contains("mention-literal-key"),
|
|
wrap: getComputedStyle(chip).overflowWrap,
|
|
iconDisplay: getComputedStyle(chip, "::before").display,
|
|
rects: [...chip.getClientRects()].map((r) => ({
|
|
left: r.left - bounds.left,
|
|
right: r.right - bounds.left,
|
|
width: r.width,
|
|
})),
|
|
})),
|
|
};
|
|
});
|
|
const assertFits = async (
|
|
host: import("@playwright/test").Locator,
|
|
stage: string,
|
|
) => {
|
|
if (stage === "sent") {
|
|
await expect(host.locator("[data-mention]")).toHaveCount(2);
|
|
await expect(host.locator("[data-mention]").last()).toHaveAttribute(
|
|
"data-mention-kind",
|
|
kind === "agent" ? "agent" : "human",
|
|
);
|
|
}
|
|
const result = await geometry(host);
|
|
expect(result.chips.length).toBeGreaterThanOrEqual(2);
|
|
expect(result.scrollWidth).toBeLessThanOrEqual(result.clientWidth + 1);
|
|
for (const chip of result.chips) {
|
|
expect(chip.wrap).toBe("anywhere");
|
|
if (stage !== "sent") {
|
|
expect(chip.iconDisplay).toBe(
|
|
chip.literalKey ? "none" : "inline-block",
|
|
);
|
|
}
|
|
for (const rect of chip.rects) {
|
|
expect(rect.left).toBeGreaterThanOrEqual(-1);
|
|
expect(rect.right).toBeLessThanOrEqual(result.width + 1);
|
|
}
|
|
}
|
|
if (stage === "sent") {
|
|
for (const chip of await host.locator("[data-mention]").all()) {
|
|
const expectedKind =
|
|
kind === "agent" ||
|
|
(await chip.getAttribute("data-mention-pubkey")) === FIRST
|
|
? "agent"
|
|
: "human";
|
|
await expect(chip).toHaveAttribute("data-mention-kind", expectedKind);
|
|
const leading = chip.locator(".inline-chip-leading-fragment");
|
|
await expect(leading).toHaveText("Scout");
|
|
expect(await leading.ariaSnapshot()).toContain("Scout");
|
|
const icon = await leading.evaluate((element) => {
|
|
const style = getComputedStyle(element, "::before");
|
|
return {
|
|
display: style.display,
|
|
mask: style.maskImage,
|
|
width: parseFloat(style.width),
|
|
height: parseFloat(style.height),
|
|
};
|
|
});
|
|
expect(icon.display).toBe("block");
|
|
expect(icon.mask).toContain("data:image/svg+xml");
|
|
expect(icon.width).toBeGreaterThan(0);
|
|
expect(icon.height).toBeGreaterThan(0);
|
|
await expect(leading).toHaveClass(
|
|
new RegExp(`inline-chip-icon-${expectedKind}`),
|
|
);
|
|
}
|
|
} else {
|
|
for (const prefix of await host
|
|
.locator(".mention-prefix-hidden")
|
|
.all()) {
|
|
const literalKey = await prefix.evaluate((element) =>
|
|
element.classList.contains("mention-literal-key"),
|
|
);
|
|
await expect(prefix).toHaveCSS("opacity", literalKey ? "1" : "0");
|
|
await expect(prefix).toHaveCSS(
|
|
"display",
|
|
literalKey ? "inline" : "inline-block",
|
|
);
|
|
}
|
|
}
|
|
await testInfo.attach(`${stage}-geometry`, {
|
|
body: JSON.stringify(result, null, 2),
|
|
contentType: "application/json",
|
|
});
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
path: `test-results/mention-recipients/layout-${kind}-${scale}-${stage}.png`,
|
|
});
|
|
};
|
|
await expect(input).toHaveText(content);
|
|
await assertFits(input, "composer");
|
|
await page.getByTestId("send-message").click();
|
|
await expect
|
|
.poll(() => recipients(page, content))
|
|
.toEqual([[FIRST, SECOND]]);
|
|
const row = page
|
|
.getByTestId("message-timeline")
|
|
.getByTestId("message-row")
|
|
.last();
|
|
await expect(row).toContainText("layout journey");
|
|
const markdown = row
|
|
.locator(".message-markdown")
|
|
.filter({ hasText: "layout journey" })
|
|
.last();
|
|
await assertFits(markdown, "sent");
|
|
const qualifiedChip = row.locator("[data-mention]").last();
|
|
await expect(qualifiedChip).toHaveText(`Scout (${truncateNpub(SECOND)})`);
|
|
await expect(qualifiedChip).toHaveAttribute(
|
|
"data-mention-label",
|
|
`Scout (${SECOND})`,
|
|
);
|
|
await expect(qualifiedChip).toHaveAttribute("title", `Scout (${SECOND})`);
|
|
await expect(row.locator("[data-mention]").last()).toHaveAttribute(
|
|
"aria-label",
|
|
`Scout (${SECOND})`,
|
|
);
|
|
await row.hover();
|
|
await row.getByRole("button", { name: "More actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
await expect(input).toHaveText(content);
|
|
await assertFits(input, "reopen");
|
|
});
|
|
}
|
|
|
|
for (const replacement of [
|
|
"@Charlie repaired history",
|
|
"plain repaired history",
|
|
]) {
|
|
test(`historical ambiguous thread edit to ${replacement} reopens and forwards without stale keys`, async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
searchProfiles: [
|
|
{ pubkey: FIRST, displayName: "Scout" },
|
|
{ pubkey: SECOND, displayName: "Scout" },
|
|
{ pubkey: TEST_IDENTITIES.charlie.pubkey, displayName: "Charlie" },
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.waitForFunction(() =>
|
|
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
|
channelName: "general",
|
|
}),
|
|
);
|
|
const ids = await page.evaluate(
|
|
({ first, second }) => {
|
|
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) throw new Error("Missing emitter");
|
|
const root = emit({
|
|
channelName: "general",
|
|
content: "Historical edit source thread",
|
|
pubkey: first,
|
|
});
|
|
const reply = emit({
|
|
channelName: "general",
|
|
content: "@Scout historical ambiguity",
|
|
mentionPubkeys: [first, second],
|
|
parentEventId: root.id,
|
|
});
|
|
return { root: root.id, reply: reply.id };
|
|
},
|
|
{ first: FIRST, second: SECOND },
|
|
);
|
|
await page
|
|
.locator(
|
|
`[data-testid="message-thread-summary"][data-thread-head-id="${ids.root}"]`,
|
|
)
|
|
.click();
|
|
const panel = page.getByTestId("message-thread-panel");
|
|
const row = panel.locator(`[data-message-id="${ids.reply}"]`);
|
|
const openMenu = async () => {
|
|
await waitForAnimations(page);
|
|
await row.hover();
|
|
await row.getByTestId(`more-actions-${ids.reply}`).click();
|
|
};
|
|
await expect(row).toContainText("historical ambiguity");
|
|
await openMenu();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
const input = panel.getByTestId("message-input");
|
|
// Edit starts after the menu exit lifecycle; do not send a new thread reply
|
|
// by filling the still-idle composer before the historical body is loaded.
|
|
await expect(panel.getByTestId("edit-target")).toBeVisible();
|
|
await expect(input).toHaveText("@Scout historical ambiguity");
|
|
await input.fill(replacement);
|
|
await panel.getByTestId("send-message").click();
|
|
await expect(row).toContainText("repaired history");
|
|
const expected = replacement.startsWith("@")
|
|
? [TEST_IDENTITIES.charlie.pubkey]
|
|
: [];
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const call = window.__BUZZ_E2E_COMMAND_LOG__
|
|
?.filter((call) => call.command === "edit_message")
|
|
.at(-1);
|
|
const args = (
|
|
call?.payload as {
|
|
input?: { mentionTags?: string[][]; mentionPubkeys?: string[] };
|
|
}
|
|
)?.input;
|
|
return {
|
|
refs: args?.mentionTags ?? [],
|
|
notifying: args?.mentionPubkeys ?? [],
|
|
};
|
|
}),
|
|
)
|
|
.toEqual({
|
|
refs: expected.map((key) => ["mention", key]),
|
|
notifying: expected,
|
|
});
|
|
await openMenu();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
await expect(input).toHaveText(replacement);
|
|
// Save the unchanged reopened snapshot, then forward through the actual menu.
|
|
await panel.getByTestId("send-message").click();
|
|
await expect(panel.getByTestId("edit-target")).toHaveCount(0);
|
|
await openMenu();
|
|
await page.getByRole("menuitem", { name: "Send to channel" }).click();
|
|
await expect.poll(() => recipients(page, replacement)).toEqual([expected]);
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
path: `test-results/mention-recipients/history-${expected.length ? "replacement" : "removed"}-forwarded.png`,
|
|
});
|
|
});
|
|
}
|
|
|
|
for (const mixed of [false, true]) {
|
|
test(`absent-roster overlapping history (${mixed ? "mixed" : "ambiguous"}) saves, reopens and forwards only the longer alias`, async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const short = (mixed ? ["1"] : ["1", "2"]).map((key) => key.repeat(64));
|
|
const long = ["3", "4"].map((key) => key.repeat(64));
|
|
const replacement = "@Scout Jones repaired history";
|
|
await installMockBridge(page, {
|
|
searchProfiles: [
|
|
...short.map((pubkey) => ({ pubkey, displayName: "Scout" })),
|
|
...long.map((pubkey) => ({ pubkey, displayName: "Scout Jones" })),
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.waitForFunction(() =>
|
|
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
|
channelName: "general",
|
|
}),
|
|
);
|
|
const ids = await page.evaluate(
|
|
({ delivered }) => {
|
|
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) throw new Error("Missing emitter");
|
|
const root = emit({
|
|
channelName: "general",
|
|
content: "Historical edit source thread",
|
|
});
|
|
const reply = emit({
|
|
channelName: "general",
|
|
content: "@Scout and @Scout Jones historical ambiguity",
|
|
mentionPubkeys: delivered,
|
|
parentEventId: root.id,
|
|
});
|
|
return { root: root.id, reply: reply.id };
|
|
},
|
|
{ delivered: [...short, ...long] },
|
|
);
|
|
await page
|
|
.locator(
|
|
`[data-testid="message-thread-summary"][data-thread-head-id="${ids.root}"]`,
|
|
)
|
|
.click();
|
|
const panel = page.getByTestId("message-thread-panel");
|
|
const row = panel.locator(`[data-message-id="${ids.reply}"]`);
|
|
const openMenu = async () => {
|
|
await waitForAnimations(page);
|
|
await row.hover();
|
|
await row.getByTestId(`more-actions-${ids.reply}`).click();
|
|
};
|
|
await expect(row).toContainText("historical ambiguity");
|
|
await openMenu();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
const input = panel.getByTestId("message-input");
|
|
// Edit starts after the menu exit lifecycle; do not send a new thread reply
|
|
// by filling the still-idle composer before the historical body is loaded.
|
|
await expect(panel.getByTestId("edit-target")).toBeVisible();
|
|
await expect(input).toHaveText(
|
|
"@Scout and @Scout Jones historical ambiguity",
|
|
);
|
|
await input.fill(replacement);
|
|
await panel.getByTestId("send-message").click();
|
|
await expect(row).toContainText("repaired history");
|
|
const expected = long;
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const call = window.__BUZZ_E2E_COMMAND_LOG__
|
|
?.filter((call) => call.command === "edit_message")
|
|
.at(-1);
|
|
const args = (
|
|
call?.payload as {
|
|
input?: { mentionTags?: string[][]; mentionPubkeys?: string[] };
|
|
}
|
|
)?.input;
|
|
return {
|
|
refs: args?.mentionTags ?? [],
|
|
notifying: args?.mentionPubkeys ?? [],
|
|
};
|
|
}),
|
|
)
|
|
.toEqual({
|
|
refs: expected.map((key) => ["mention", key]),
|
|
notifying: [],
|
|
});
|
|
await openMenu();
|
|
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
|
await expect(input).toHaveText(replacement);
|
|
// Save the unchanged reopened snapshot, then forward through the actual menu.
|
|
await panel.getByTestId("send-message").click();
|
|
await expect(panel.getByTestId("edit-target")).toHaveCount(0);
|
|
await openMenu();
|
|
await page.getByRole("menuitem", { name: "Send to channel" }).click();
|
|
await expect.poll(() => recipients(page, replacement)).toEqual([expected]);
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
path: testInfo.outputPath(
|
|
`absent-roster-${mixed ? "mixed" : "ambiguous"}-forwarded.png`,
|
|
),
|
|
});
|
|
});
|
|
}
|
|
|
|
for (const selection of ["picker", "automatic"]) {
|
|
test(`qualified ${selection} selection retires a pending paste of the original label`, async ({
|
|
page,
|
|
}) => {
|
|
const [a, b, pasted] = ["a".repeat(64), "b".repeat(64), "c".repeat(64)];
|
|
await page.addInitScript(() =>
|
|
localStorage.setItem("buzz.messages.keepMentionedAgentsPinned", "true"),
|
|
);
|
|
await installMockBridge(page, {
|
|
managedAgents: [a, b].map((pubkey) => ({
|
|
pubkey,
|
|
name: "Scout",
|
|
status: "running",
|
|
channelNames: ["general"],
|
|
})),
|
|
searchProfiles: [{ pubkey: pasted, displayName: "Scout" }],
|
|
});
|
|
await page.goto(
|
|
"/#/channels/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50?messageId=mock-general-welcome&thread=mock-general-welcome",
|
|
);
|
|
const composer = page.getByTestId("thread-composer-overlay");
|
|
const input = composer.getByTestId("message-input");
|
|
await input.fill("@Scout");
|
|
await composer.getByTestId(`mention-suggestion-${a}`).click();
|
|
await page.evaluate(() => window.__BUZZ_E2E_HOLD_USERS_BATCH__?.(true));
|
|
await input.evaluate((element, pubkey) => {
|
|
const clipboardData = new DataTransfer();
|
|
clipboardData.setData("text/plain", "@Scout pasted ");
|
|
clipboardData.setData(
|
|
"text/html",
|
|
`<span data-buzz-copy="markdown"><span data-mention="" data-mention-label="Scout" data-mention-pubkey="${pubkey}">@Scout</span> pasted </span>`,
|
|
);
|
|
element.dispatchEvent(
|
|
new ClipboardEvent("paste", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clipboardData,
|
|
}),
|
|
);
|
|
}, pasted);
|
|
await expect(input).toHaveText("@Scout @Scout pasted ");
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => window.__BUZZ_E2E_USERS_BATCH_PENDING__?.() ?? 0),
|
|
)
|
|
.toBeGreaterThan(0);
|
|
if (selection === "picker") {
|
|
await page.keyboard.type("@Scout");
|
|
await composer.getByTestId(`mention-suggestion-${b}`).click();
|
|
} else {
|
|
await composer.locator("[data-mention-picker-trigger]").click();
|
|
await composer.getByTestId(`mention-always-address-${b}`).click();
|
|
await input.press("Escape");
|
|
}
|
|
await expect(input).toContainText(`@Scout (${b})`);
|
|
const content = (await input.innerText()).trim();
|
|
expect(
|
|
await page.evaluate(
|
|
() => window.__BUZZ_E2E_HOLD_USERS_BATCH__?.(false) ?? 0,
|
|
),
|
|
).toBeGreaterThan(0);
|
|
await composer.getByTestId("send-message").click();
|
|
await expect(input).toHaveText(`@Scout @Scout (${b}) `);
|
|
await expect.poll(() => recipients(page, content)).toEqual([[a, b]]);
|
|
});
|
|
}
|
|
|
|
for (const mismatchedKey of [false, true]) {
|
|
test(`qualified chip copy/paste ${mismatchedKey ? "rejects a mismatched key" : "preserves its exact recipient"}`, async ({
|
|
page,
|
|
}) => {
|
|
await install(page);
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@Scout");
|
|
await page.getByTestId(`mention-suggestion-${FIRST}`).click();
|
|
await page.keyboard.type("and @Scout");
|
|
await page.getByTestId(`mention-suggestion-${SECOND}`).click();
|
|
await page.keyboard.type("qualified clipboard roundtrip");
|
|
await page.getByTestId("send-message").click();
|
|
const chip = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "qualified clipboard roundtrip" })
|
|
.locator(`[data-mention-pubkey="${SECOND}"]`);
|
|
await expect(chip).toHaveText(`Scout (${truncateNpub(SECOND)})`);
|
|
const flavors = await chip.evaluate((element) => {
|
|
const range = document.createRange();
|
|
range.selectNode(element);
|
|
const selection = window.getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
const clipboardData = new DataTransfer();
|
|
const event = new ClipboardEvent("copy", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clipboardData,
|
|
});
|
|
element.dispatchEvent(event);
|
|
return {
|
|
handled: event.defaultPrevented,
|
|
text: clipboardData.getData("text/plain"),
|
|
html: clipboardData.getData("text/html"),
|
|
};
|
|
});
|
|
expect(flavors.handled).toBe(true);
|
|
expect(flavors.text.trim()).toBe(`@Scout (${SECOND})`);
|
|
expect(flavors.html).toContain(`data-mention-pubkey="${SECOND}"`);
|
|
if (mismatchedKey) {
|
|
// Both keys have the trusted alias Scout; the qualifier must ALSO agree.
|
|
flavors.html = flavors.html.replace(
|
|
`data-mention-pubkey="${SECOND}"`,
|
|
`data-mention-pubkey="${FIRST}"`,
|
|
);
|
|
}
|
|
// Remount to discard source-composer selections. A channel (not a DM)
|
|
// also avoids an automatic conversation p tag masking a lost mention.
|
|
await page.reload();
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await input.focus();
|
|
await input.evaluate((element, { text, html }) => {
|
|
const clipboardData = new DataTransfer();
|
|
clipboardData.setData("text/plain", text);
|
|
clipboardData.setData("text/html", html);
|
|
element.dispatchEvent(
|
|
new ClipboardEvent("paste", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clipboardData,
|
|
}),
|
|
);
|
|
}, flavors);
|
|
await expect(input).toHaveText(flavors.text);
|
|
await page.getByTestId("send-message").click();
|
|
await expect
|
|
.poll(() => recipients(page, flavors.text.trim()))
|
|
.toEqual([mismatchedKey ? [] : [SECOND]]);
|
|
});
|
|
}
|
|
|
|
for (const partial of [false, true]) {
|
|
test(`matching abbreviated keys ${partial ? "do not bind a partial copy" : "retain separate exact recipients through copy and paste"}`, async ({
|
|
page,
|
|
}) => {
|
|
const keys = ["a", "b"].map((middle) => `150b20bd${middle.repeat(52)}15dc`);
|
|
await installMockBridge(page, {
|
|
searchProfiles: keys.map((pubkey) => ({ pubkey, displayName: "Scout" })),
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.waitForFunction(() =>
|
|
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
|
channelName: "general",
|
|
}),
|
|
);
|
|
await page.evaluate((keys) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "general",
|
|
content: `@Scout (${keys[0]}) and @Scout (${keys[1]}) compact collision`,
|
|
mentionPubkeys: keys,
|
|
});
|
|
}, keys);
|
|
const row = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "compact collision" });
|
|
for (const key of keys) {
|
|
const chip = row.locator(`[data-mention-pubkey="${key}"]`);
|
|
await expect(chip).toHaveText(`Scout (${truncateNpub(key)})`);
|
|
await expect(chip).toHaveAttribute("title", `Scout (${key})`);
|
|
const flavors = await chip.evaluate((element, partial) => {
|
|
const range = document.createRange();
|
|
range.selectNode(element);
|
|
if (partial) {
|
|
const leadingText = document
|
|
.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
|
.nextNode();
|
|
if (!leadingText) throw new Error("Missing mention text");
|
|
range.setStart(leadingText, 0);
|
|
range.setEnd(leadingText, 5);
|
|
}
|
|
const selection = window.getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
const clipboardData = new DataTransfer();
|
|
const event = new ClipboardEvent("copy", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clipboardData,
|
|
});
|
|
element.dispatchEvent(event);
|
|
// Model the browser's default HTML serialization if our handler declines.
|
|
const fallback = document.createElement("div");
|
|
fallback.append(range.cloneContents());
|
|
return {
|
|
handled: event.defaultPrevented,
|
|
text: event.defaultPrevented
|
|
? clipboardData.getData("text/plain")
|
|
: (selection?.toString() ?? ""),
|
|
html: event.defaultPrevented
|
|
? clipboardData.getData("text/html")
|
|
: fallback.innerHTML,
|
|
};
|
|
}, partial);
|
|
expect(flavors.handled).toBe(!partial);
|
|
expect(flavors.text.trim()).toBe(partial ? "Scout" : `@Scout (${key})`);
|
|
const input = page.getByTestId("message-input");
|
|
await input.focus();
|
|
await input.evaluate((element, flavors) => {
|
|
const clipboardData = new DataTransfer();
|
|
clipboardData.setData("text/plain", flavors.text);
|
|
clipboardData.setData("text/html", flavors.html);
|
|
element.dispatchEvent(
|
|
new ClipboardEvent("paste", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clipboardData,
|
|
}),
|
|
);
|
|
}, flavors);
|
|
const marker = ` copied-${keys.indexOf(key)}`;
|
|
await page.keyboard.type(marker);
|
|
const content = `${flavors.text}${marker}`;
|
|
await expect(input).toHaveText(content);
|
|
await page.getByTestId("send-message").click();
|
|
if (!partial) {
|
|
await page
|
|
.getByRole("alertdialog")
|
|
.getByRole("button", { name: "Invite", exact: true })
|
|
.click();
|
|
}
|
|
// Chromium can preserve the typed separator as NBSP after a rich paste.
|
|
// Assert the full literal body and exact tags, tolerating only that space.
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(marker) =>
|
|
(window.__BUZZ_E2E_SIGNED_EVENTS__ ?? [])
|
|
.filter(
|
|
(event) =>
|
|
event.kind === 9 && event.content.endsWith(marker.trim()),
|
|
)
|
|
.map((event) => ({
|
|
content: event.content.replace(/\u00a0/g, " ").trim(),
|
|
keys: event.tags
|
|
.filter((tag) => tag[0] === "p")
|
|
.map((tag) => tag[1]),
|
|
})),
|
|
marker,
|
|
),
|
|
)
|
|
.toEqual([{ content: content.trim(), keys: partial ? [] : [key] }]);
|
|
}
|
|
});
|
|
}
|