mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
## Overview **Category:** fix **User Impact:** Agent avatars and empty agent-team placeholders now keep the same intentional squircle silhouette at every size on desktop, while human avatars remain circular. **Problem:** Desktop agent avatars used percentage-rounded rectangles, which made the silhouette vary by surface and required each avatar implementation to recreate the shape. Empty team placeholders also drew a regular CSS border inside the clip, leaving straight inset edges that looked like a cropped square. **Solution:** Reuse one normalized SVG clip path across desktop artwork. Empty team placeholders use a separately clipped squircle outline behind the clipped surface, so both the fill and outline follow the canonical silhouette. > **Scope correction:** Mobile is deferred from this PR because a real Flutter capture could not be obtained reliably in the current build environment. Previously attached mobile images were hand-composed synthetic layouts, not captures from the running app; they have been removed and must not be treated as validation. The mobile implementation is being reverted in one follow-up commit so it can be reapplied and verified separately. ### Related issue Follow-up to #7106. ## Changes <details> <summary>File changes</summary> **desktop/src/features/agents/ui/AgentCreationPreview.tsx** Applies the shared squircle clip to agent creation preview images and fallbacks. **desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx** Uses the shared agent-avatar silhouette in compact session summaries. **desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx** Clips agent initials with the normalized squircle instead of a percentage radius. **desktop/src/features/agents/ui/TeamIdentityCard.tsx** Uses the shared squircle for agent identities and gives empty teams a separately clipped squircle outline instead of a regular inset border. **desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx** Keeps agent avatars in activity message bubbles on the canonical silhouette. **desktop/src/features/home/ui/InboxListPane.tsx** Applies the shared clip to agent avatars in the inbox list. **desktop/src/features/home/ui/InboxMessageRow.tsx** Applies the shared clip to agent avatars in inbox message rows. **desktop/src/features/huddle/components/ParticipantList.tsx** Uses the normalized squircle for agent participants while preserving circular human avatars. **desktop/src/features/messages/ui/MessageRow.tsx** Clips agent message avatars through the shared definition. **desktop/src/features/messages/ui/SystemMessageAvatars.tsx** Uses the canonical agent shape in system-message avatar groups. **desktop/src/features/messages/ui/TypingIndicatorRow.tsx** Uses the canonical agent shape in typing indicators. **desktop/src/features/profile/ui/ProfileAvatar.tsx** Scales the same squircle to the larger agent profile avatar. **desktop/src/features/profile/ui/SelectedRecipientChip.tsx** Uses the shared shape for selected agent recipients. **desktop/src/features/projects/ui/IssueAssigneesRow.tsx** Applies the agent squircle to issue assignees without changing human avatar geometry. **desktop/src/features/projects/ui/ProjectCards.tsx** Uses the canonical shape for agent avatars on project cards. **desktop/src/features/projects/ui/ProjectEntityListRow.tsx** Uses the canonical shape for agent identities in project entity lists. **desktop/src/features/projects/ui/ProjectsActivityFeed.tsx** Uses the shared clip for agent avatars in project activity. **desktop/src/features/projects/ui/ProjectsOverviewRail.tsx** Uses the shared clip for agent avatars in the project overview rail. **desktop/src/main.tsx** Mounts the single shared SVG clip-path definition once for the desktop application. **desktop/src/shared/lib/cn.ts** Registers `rounded-squircle` in the border-radius class group so shadcn-style `cn(...)` composition resolves it against `rounded-full` by normal last-class precedence. **desktop/src/shared/styles/globals/utilities.css** Registers the reusable Tailwind `rounded-squircle` utility and references the normalized SVG clip path without agent-specific CSS selectors. **desktop/src/shared/ui/AvatarClipPaths.tsx** Owns the reusable normalized object-bounding-box `rounded-squircle` path so all desktop sizes reuse one definition. **desktop/src/shared/ui/UserAvatar.tsx** Routes the existing squircle shape option through the shared SVG clip. **desktop/tests/e2e/agents.spec.ts** Verifies team facepile and empty-team placeholder surfaces/outlines use the canonical clip without a rectangular border. **desktop/tests/e2e/messaging.spec.ts** Verifies agent message and profile avatars reuse one clip definition and scale across rendered sizes. </details> ## Reproduction steps 1. Run the desktop app and open a channel containing both agent and human messages. 2. Compare agent avatars in the message list, inbox, huddle participants, projects, and profile panel. Agent images and fallbacks should share one smooth squircle silhouette at each size; human avatars should remain circular. 3. Open **Agents** and inspect an empty agent-team card. Its placeholder fill and outline should both be smooth squircles, with no straight inset cropped-square edges. 4. Open an agent profile from a message and confirm the larger profile avatar keeps the same proportions as the smaller message avatar. ## Testing - Desktop Biome check passed for the changed empty-team component and Agents E2E spec. - Desktop typecheck and differential file-size check passed. - Full desktop unit suite passed: 6,453 tests. - Full Agents integration spec passed: 37/37, including the empty-team outline regression. - Post-rebase pre-push gates passed at `304b159a2`: desktop checks, TypeScript typecheck, differential file-size gate, and all 6,453 desktop unit tests. - The shadcn-aligned `rounded-squircle` registration passed changed-file Biome, desktop TypeScript/build, and pre-push desktop checks at `27e0c12d3`; the full desktop unit suite passed 6,459/6,459. ## Screenshots Screenshots are temporarily omitted. The previous captures used the desktop E2E mock bridge and were not evidence from a live running app, so they were removed. --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz> Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
5171 lines
170 KiB
TypeScript
5171 lines
170 KiB
TypeScript
import { expect, test, type Locator } from "@playwright/test";
|
|
|
|
import { truncateNpub } from "../../src/shared/lib/pubkey";
|
|
import { waitForAnimations } from "../helpers/animations";
|
|
|
|
import {
|
|
installMockBridge,
|
|
openChannelBrowser,
|
|
TEST_IDENTITIES,
|
|
} from "../helpers/bridge";
|
|
|
|
const MOCK_VIEWER_PUBKEY = "deadbeef".repeat(8);
|
|
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await installMockBridge(page);
|
|
});
|
|
|
|
test.afterEach(async ({ page }, testInfo) => {
|
|
if (testInfo.status !== testInfo.expectedStatus) {
|
|
await testInfo.attach("outgoing-diagnostic", {
|
|
body: JSON.stringify(
|
|
await page.evaluate(() => ({
|
|
events: window.__BUZZ_E2E_SIGNED_EVENTS__,
|
|
commands: window.__BUZZ_E2E_COMMAND_LOG__,
|
|
})),
|
|
),
|
|
contentType: "application/json",
|
|
});
|
|
}
|
|
});
|
|
|
|
const IN_CHANNEL_MANAGED_AGENT_PUBKEY =
|
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
const OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY =
|
|
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
const OUT_OF_CHANNEL_PROVIDER_AGENT_PUBKEY =
|
|
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
|
|
const REUSABLE_PERSONA_AGENT_PUBKEY =
|
|
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
|
|
const ALLOWLIST_RELAY_AGENT_PUBKEY =
|
|
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
|
|
const DELAYED_RELAY_AGENT_PUBKEY =
|
|
"9999999999999999999999999999999999999999999999999999999999999999";
|
|
const CASEY_PROFILE_PUBKEY =
|
|
"1111111111111111111111111111111111111111111111111111111111111111";
|
|
const PROFILE_ONLY_AGENT_PUBKEY =
|
|
"8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67";
|
|
const OWNED_AGENT_PROFILE_PUBKEY =
|
|
"1212121212121212121212121212121212121212121212121212121212121212";
|
|
const HUDDLE_EPHEMERAL_CHANNEL_ID = "3f9f2c4e-8b7a-4b1c-9d2e-5a6f7c8d9e0f";
|
|
const ELROND_PUBKEY = "31".repeat(32);
|
|
const LEGOLAS_PUBKEY = "32".repeat(32);
|
|
const GIMLI_PUBKEY = "33".repeat(32);
|
|
const GANDALF_PUBKEY = "34".repeat(32);
|
|
const STANDARD_GROUPED_ARRIVAL_ACTOR = {
|
|
pubkey: "10".repeat(32),
|
|
displayName: "Alice Chen",
|
|
};
|
|
const STANDARD_GROUPED_ARRIVAL_TARGETS = [
|
|
{ pubkey: "11".repeat(32), displayName: "Erica Chapman" },
|
|
{ pubkey: "12".repeat(32), displayName: "Peter Griffin" },
|
|
{ pubkey: "13".repeat(32), displayName: "Marcia Thomas" },
|
|
{ pubkey: "14".repeat(32), displayName: "Jordan Lee" },
|
|
{ pubkey: "15".repeat(32), displayName: "Olivia Park" },
|
|
{ pubkey: "16".repeat(32), displayName: "Sam Rivera" },
|
|
];
|
|
const JOIN_COLLAPSE_PROFILES = [
|
|
{ pubkey: ELROND_PUBKEY, displayName: "Elrond" },
|
|
{ pubkey: LEGOLAS_PUBKEY, displayName: "Legolas" },
|
|
{ pubkey: GIMLI_PUBKEY, displayName: "Gimli" },
|
|
{ pubkey: GANDALF_PUBKEY, displayName: "Gandalf" },
|
|
];
|
|
const JOIN_COLLAPSE_CHANNEL_NAME = "random";
|
|
const SYSTEM_MESSAGE_KIND = 40099;
|
|
const DM_THREAD_AGENT_MENTION_ERROR_TEXT =
|
|
"Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent.";
|
|
const DM_THREAD_MEMBERS_LOADING_ERROR_TEXT =
|
|
"Checking conversation members. Try again in a moment.";
|
|
const JOIN_COLLAPSE_SPLIT_TEXTS = [
|
|
"Elrond added by you",
|
|
"Legolas added by Elrond, along with Gimli",
|
|
"Gandalf added by you",
|
|
];
|
|
const JOIN_COLLAPSE_GROUPED_TEXT =
|
|
"Elrond was added along with Legolas, Gimli, and Gandalf";
|
|
const JOIN_COLLAPSE_CAPTURE_WIDTH = 560;
|
|
const JOIN_COLLAPSE_CAPTURE_HEIGHT = 260;
|
|
const JOIN_COLLAPSE_CAPTURE_VERTICAL_PADDING = 24;
|
|
|
|
async function timelineChipLayout(chip: Locator) {
|
|
return chip.evaluate((element) => {
|
|
const paragraph = element.closest("p");
|
|
if (!paragraph) throw new Error("Timeline chip is missing its paragraph");
|
|
const chipBounds = element.getBoundingClientRect();
|
|
const paragraphBounds = paragraph.getBoundingClientRect();
|
|
const chipFragmentRects = Array.from(element.getClientRects())
|
|
.filter((rect) => rect.width > 0 && rect.height > 0)
|
|
.sort((a, b) => a.top - b.top);
|
|
const chipStyle = getComputedStyle(element);
|
|
return {
|
|
boxDecorationBreak:
|
|
chipStyle.getPropertyValue("box-decoration-break") ||
|
|
chipStyle.getPropertyValue("-webkit-box-decoration-break"),
|
|
chipHeight: chipBounds.height,
|
|
chipLineHeight: Number.parseFloat(chipStyle.lineHeight),
|
|
fragmentCount: chipFragmentRects.length,
|
|
fragmentGap:
|
|
chipFragmentRects.length > 1
|
|
? Math.round(chipFragmentRects[1].top - chipFragmentRects[0].bottom)
|
|
: null,
|
|
fragmentHeight:
|
|
chipFragmentRects.length > 0
|
|
? Math.round(chipFragmentRects[0].height)
|
|
: null,
|
|
fragmentStep:
|
|
chipFragmentRects.length > 1
|
|
? Math.round(chipFragmentRects[1].top - chipFragmentRects[0].top)
|
|
: null,
|
|
paragraphHeight: paragraphBounds.height,
|
|
paragraphLineHeight: Number.parseFloat(
|
|
getComputedStyle(paragraph).lineHeight,
|
|
),
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Locator scoped to the mention autocomplete dropdown inside the composer. */
|
|
function autocomplete(page: import("@playwright/test").Page) {
|
|
return page
|
|
.getByTestId("message-composer")
|
|
.getByTestId("mention-autocomplete");
|
|
}
|
|
|
|
async function readCommandLog(page: import("@playwright/test").Page) {
|
|
return page.evaluate(() => {
|
|
return (
|
|
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
|
|
.__BUZZ_E2E_COMMANDS__ ?? []
|
|
);
|
|
});
|
|
}
|
|
|
|
async function readCommandPayloadLog(page: import("@playwright/test").Page) {
|
|
return page.evaluate(() => {
|
|
return (
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_COMMAND_LOG__?: Array<{
|
|
command: string;
|
|
payload: unknown;
|
|
}>;
|
|
}
|
|
).__BUZZ_E2E_COMMAND_LOG__ ?? []
|
|
);
|
|
});
|
|
}
|
|
|
|
async function readOutgoingMentionPubkeys(
|
|
page: import("@playwright/test").Page,
|
|
content: string,
|
|
) {
|
|
return page.evaluate((expectedContent) => {
|
|
const signedEvent = (
|
|
window as Window & {
|
|
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{
|
|
content?: string;
|
|
tags?: string[][];
|
|
}>;
|
|
}
|
|
).__BUZZ_E2E_SIGNED_EVENTS__?.find(
|
|
(event) => event.content === expectedContent,
|
|
);
|
|
if (signedEvent) {
|
|
return (signedEvent.tags ?? [])
|
|
.filter((tag) => tag[0] === "p" && tag[1])
|
|
.map((tag) => tag[1]);
|
|
}
|
|
|
|
const entries =
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_COMMAND_LOG__?: Array<{
|
|
command: string;
|
|
payload: unknown;
|
|
}>;
|
|
}
|
|
).__BUZZ_E2E_COMMAND_LOG__ ?? [];
|
|
|
|
for (const entry of entries) {
|
|
if (entry.command === "send_channel_message") {
|
|
const payload = entry.payload as
|
|
| { content?: string; mentionPubkeys?: string[] }
|
|
| undefined;
|
|
if (payload?.content === expectedContent) {
|
|
return payload.mentionPubkeys ?? [];
|
|
}
|
|
}
|
|
|
|
if (entry.command === "sign_event") {
|
|
const unsignedEvent = entry.payload as
|
|
| { content?: string; tags?: string[][] }
|
|
| undefined;
|
|
if (unsignedEvent?.content !== expectedContent) continue;
|
|
return (unsignedEvent.tags ?? [])
|
|
.filter((tag) => tag[0] === "p" && tag[1])
|
|
.map((tag) => tag[1]);
|
|
}
|
|
|
|
if (entry.command !== "plugin:websocket|send") continue;
|
|
const data = (
|
|
entry.payload as { message?: { data?: string } } | undefined
|
|
)?.message?.data;
|
|
if (!data) continue;
|
|
|
|
try {
|
|
const frame = JSON.parse(data) as [
|
|
string,
|
|
{ content?: string; tags?: string[][] },
|
|
];
|
|
if (frame[0] !== "EVENT" || frame[1]?.content !== expectedContent) {
|
|
continue;
|
|
}
|
|
return (frame[1].tags ?? [])
|
|
.filter((tag) => tag[0] === "p" && tag[1])
|
|
.map((tag) => tag[1]);
|
|
} catch {}
|
|
}
|
|
|
|
return null;
|
|
}, content);
|
|
}
|
|
|
|
function commandCount(commands: string[], command: string) {
|
|
return commands.filter((entry) => entry === command).length;
|
|
}
|
|
|
|
async function emitMockMessage(
|
|
page: import("@playwright/test").Page,
|
|
channelName: string,
|
|
content: string,
|
|
options?: {
|
|
kind?: number;
|
|
mentionPubkeys?: string[];
|
|
parentEventId?: string;
|
|
pubkey?: string;
|
|
},
|
|
) {
|
|
const event = await page.evaluate(
|
|
({ ch, kind, mentionPubkeys, msg, parentEventId, pubkey }) => {
|
|
return (
|
|
window as Window & {
|
|
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
|
channelName: string;
|
|
content: string;
|
|
kind?: number;
|
|
mentionPubkeys?: string[];
|
|
parentEventId?: string | null;
|
|
pubkey?: string;
|
|
}) => { id: string; created_at: number; pubkey: string };
|
|
}
|
|
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: ch,
|
|
content: msg,
|
|
kind,
|
|
mentionPubkeys,
|
|
parentEventId: parentEventId ?? undefined,
|
|
pubkey: pubkey ?? undefined,
|
|
});
|
|
},
|
|
{
|
|
ch: channelName,
|
|
kind: options?.kind,
|
|
mentionPubkeys: options?.mentionPubkeys,
|
|
msg: content,
|
|
parentEventId: options?.parentEventId ?? null,
|
|
pubkey: options?.pubkey ?? TEST_IDENTITIES.alice.pubkey,
|
|
},
|
|
);
|
|
if (!event) {
|
|
throw new Error("Mock message emitter is not installed");
|
|
}
|
|
return event;
|
|
}
|
|
|
|
async function waitForMockLiveSubscription(
|
|
page: import("@playwright/test").Page,
|
|
channelName: string,
|
|
kind?: number,
|
|
) {
|
|
await expect
|
|
.poll(async () => {
|
|
return page.evaluate(
|
|
({ currentChannelName, kind: expectedKind }) => {
|
|
return (
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
|
|
channelName: string;
|
|
kind?: number;
|
|
}) => boolean;
|
|
}
|
|
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
|
channelName: currentChannelName,
|
|
kind: expectedKind,
|
|
}) ?? false
|
|
);
|
|
},
|
|
{ currentChannelName: channelName, kind },
|
|
);
|
|
})
|
|
.toBe(true);
|
|
}
|
|
|
|
// The channel timeline renders off a `useDeferredValue` snapshot that lags the
|
|
// latest `messages` by a commit; the list wrapper carries
|
|
// `data-render-pending="true"` while that commit is in flight and drops the
|
|
// attribute once it settles. Poll for its absence before asserting on
|
|
// freshly-sent content so the assertion does not race the deferred commit.
|
|
async function waitForTimelineSettled(page: import("@playwright/test").Page) {
|
|
await expect(page.locator("[data-render-pending]")).toHaveCount(0);
|
|
}
|
|
|
|
function normalizeVisibleText(text: string) {
|
|
return text.replace(/\s+,/g, ",").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function findDuplicateFixturePubkeys(
|
|
profiles: Array<{ pubkey: string; displayName: string }>,
|
|
) {
|
|
const namesByPubkey = new Map<string, string[]>();
|
|
|
|
for (const profile of profiles) {
|
|
const names = namesByPubkey.get(profile.pubkey) ?? [];
|
|
names.push(profile.displayName);
|
|
namesByPubkey.set(profile.pubkey, names);
|
|
}
|
|
|
|
return [...namesByPubkey.entries()]
|
|
.filter(([, names]) => names.length > 1)
|
|
.map(([pubkey, displayNames]) => ({ pubkey, displayNames }));
|
|
}
|
|
|
|
async function collectJoinCollapseRows(page: import("@playwright/test").Page) {
|
|
const rows = page.getByTestId("system-message-row").filter({
|
|
hasText: /Elrond|Legolas|Gimli|Gandalf/,
|
|
});
|
|
const texts: string[] = [];
|
|
const count = await rows.count();
|
|
for (let index = 0; index < count; index += 1) {
|
|
texts.push(normalizeVisibleText(await rows.nth(index).innerText()));
|
|
}
|
|
return { rows, texts };
|
|
}
|
|
|
|
async function maybeCaptureJoinCollapseTimeline(
|
|
page: import("@playwright/test").Page,
|
|
) {
|
|
const capturePath = process.env.JOIN_COLLAPSE_CAPTURE_PATH;
|
|
if (!capturePath) return;
|
|
await waitForAnimations(page);
|
|
const timeline = page.getByTestId("message-timeline");
|
|
const timelineBox = await timeline.boundingBox();
|
|
const { rows } = await collectJoinCollapseRows(page);
|
|
const rowBoxes = await Promise.all(
|
|
Array.from({ length: await rows.count() }, (_, index) =>
|
|
rows.nth(index).boundingBox(),
|
|
),
|
|
);
|
|
const visibleRowBoxes = rowBoxes.filter(
|
|
(box): box is NonNullable<typeof box> => box !== null,
|
|
);
|
|
if (!timelineBox || visibleRowBoxes.length === 0) {
|
|
throw new Error("Join-collapse screenshot target is not visible");
|
|
}
|
|
const minRowY = Math.min(...visibleRowBoxes.map((box) => box.y));
|
|
const maxRowY = Math.max(...visibleRowBoxes.map((box) => box.y + box.height));
|
|
const minRowX = Math.min(...visibleRowBoxes.map((box) => box.x));
|
|
const maxRowX = Math.max(...visibleRowBoxes.map((box) => box.x + box.width));
|
|
const desiredTop = minRowY - JOIN_COLLAPSE_CAPTURE_VERTICAL_PADDING;
|
|
const desiredBottom = maxRowY + JOIN_COLLAPSE_CAPTURE_VERTICAL_PADDING;
|
|
const desiredCenter = (desiredTop + desiredBottom) / 2;
|
|
const desiredHorizontalCenter = (minRowX + maxRowX) / 2;
|
|
const viewportHeight = page.viewportSize()?.height ?? 720;
|
|
const clip = {
|
|
x: Math.max(
|
|
Math.round(timelineBox.x),
|
|
Math.min(
|
|
Math.round(desiredHorizontalCenter - JOIN_COLLAPSE_CAPTURE_WIDTH / 2),
|
|
Math.round(
|
|
timelineBox.x + timelineBox.width - JOIN_COLLAPSE_CAPTURE_WIDTH,
|
|
),
|
|
),
|
|
),
|
|
y: Math.max(
|
|
0,
|
|
Math.min(
|
|
Math.round(desiredCenter - JOIN_COLLAPSE_CAPTURE_HEIGHT / 2),
|
|
viewportHeight - JOIN_COLLAPSE_CAPTURE_HEIGHT,
|
|
),
|
|
),
|
|
width: JOIN_COLLAPSE_CAPTURE_WIDTH,
|
|
height: JOIN_COLLAPSE_CAPTURE_HEIGHT,
|
|
};
|
|
await page.screenshot({ path: capturePath, clip });
|
|
console.log(`JOIN_COLLAPSE_CAPTURE_PATH=${capturePath}`);
|
|
}
|
|
|
|
async function expectOwnedAgentProfileActions(
|
|
profilePopover: import("@playwright/test").Locator,
|
|
pubkey: string,
|
|
) {
|
|
await expect(
|
|
profilePopover.getByTestId(`user-profile-popover-message-${pubkey}`),
|
|
).toBeVisible();
|
|
await expect(
|
|
profilePopover.getByTestId(`user-profile-popover-wave-${pubkey}`),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
profilePopover.getByTestId(`user-profile-popover-huddle-${pubkey}`),
|
|
).toBeVisible();
|
|
}
|
|
|
|
async function expectAgentProfileActionsHidden(
|
|
profilePopover: import("@playwright/test").Locator,
|
|
pubkey: string,
|
|
) {
|
|
await expect(
|
|
profilePopover.getByTestId(`user-profile-popover-message-${pubkey}`),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
profilePopover.getByTestId(`user-profile-popover-wave-${pubkey}`),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
profilePopover.getByTestId(`user-profile-popover-huddle-${pubkey}`),
|
|
).toHaveCount(0);
|
|
}
|
|
|
|
test("@ trigger prioritizes channel members before runnable personas and other managed agents", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
activePersonaIds: ["builtin:fizz"],
|
|
managedAgents: [
|
|
{
|
|
pubkey: TEST_IDENTITIES.charlie.pubkey,
|
|
name: "charlie",
|
|
status: "stopped",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@");
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown).toBeVisible();
|
|
await expect(dropdown.getByText("alice")).toBeVisible();
|
|
await expect(dropdown.getByText("bob")).toBeVisible();
|
|
await expect(dropdown.getByText("Fizz")).toBeVisible();
|
|
await expect(dropdown.getByText("charlie")).toBeVisible();
|
|
await expect(dropdown.getByText("outsider")).toHaveCount(0);
|
|
const charlieRow = dropdown.locator("button", { hasText: "charlie" });
|
|
await expect(charlieRow.getByTestId("mention-agent-icon")).toBeVisible();
|
|
await expect(charlieRow.getByText("not in channel")).toBeVisible();
|
|
await expect(
|
|
dropdown
|
|
.locator("button", { hasText: "alice" })
|
|
.getByText("not in channel"),
|
|
).not.toBeVisible();
|
|
|
|
const suggestions = dropdown.locator("button");
|
|
const suggestionText = await suggestions.allInnerTexts();
|
|
const aliceIndex = suggestionText.findIndex((text) => text.includes("alice"));
|
|
const fizzIndex = suggestionText.findIndex((text) => text.includes("Fizz"));
|
|
const bobIndex = suggestionText.findIndex((text) => text.includes("bob"));
|
|
const charlieIndex = suggestionText.findIndex((text) =>
|
|
text.includes("charlie"),
|
|
);
|
|
const outsiderIndex = suggestionText.findIndex((text) =>
|
|
text.includes("outsider"),
|
|
);
|
|
expect(aliceIndex).toBeGreaterThanOrEqual(0);
|
|
expect(fizzIndex).toBeGreaterThanOrEqual(0);
|
|
expect(bobIndex).toBeGreaterThanOrEqual(0);
|
|
expect(charlieIndex).toBeGreaterThanOrEqual(0);
|
|
expect(outsiderIndex).toEqual(-1);
|
|
expect(aliceIndex).toBeLessThan(fizzIndex);
|
|
expect(bobIndex).toBeLessThan(fizzIndex);
|
|
expect(fizzIndex).toBeLessThan(charlieIndex);
|
|
});
|
|
|
|
test("duplicate owned agents preserve provenance and exact pubkey selection", async ({
|
|
page,
|
|
}) => {
|
|
const managedPubkey = IN_CHANNEL_MANAGED_AGENT_PUBKEY;
|
|
const relayPubkey = ALLOWLIST_RELAY_AGENT_PUBKEY;
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: managedPubkey,
|
|
name: "carl",
|
|
status: "running",
|
|
channelNames: ["general"],
|
|
backend: {
|
|
type: "provider",
|
|
id: "mock",
|
|
config: {},
|
|
},
|
|
},
|
|
],
|
|
relayAgents: [
|
|
{
|
|
pubkey: relayPubkey,
|
|
ownerPubkey: MOCK_VIEWER_PUBKEY,
|
|
name: "carl",
|
|
respondTo: "owner-only",
|
|
channelNames: ["general"],
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.evaluate(
|
|
async ({ channelId, pubkey }) => {
|
|
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) throw new Error("Mock bridge is not installed.");
|
|
await invoke("add_channel_members", {
|
|
channelId,
|
|
pubkeys: [pubkey],
|
|
role: "bot",
|
|
});
|
|
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
|
queryKey: ["channels"],
|
|
});
|
|
},
|
|
{ channelId: GENERAL_CHANNEL_ID, pubkey: relayPubkey },
|
|
);
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@carl");
|
|
const dropdown = autocomplete(page);
|
|
const managedRow = dropdown.getByTestId(
|
|
`mention-suggestion-${managedPubkey}`,
|
|
);
|
|
const relayRow = dropdown.getByTestId(`mention-suggestion-${relayPubkey}`);
|
|
await expect(managedRow).toContainText("agent");
|
|
await expect(managedRow.getByTestId("mention-agent-provenance")).toHaveCount(
|
|
0,
|
|
);
|
|
await expect(relayRow).toContainText("agent");
|
|
const relayProvenanceMarker = relayRow.getByTestId(
|
|
"mention-agent-provenance",
|
|
);
|
|
await expect(relayProvenanceMarker).toHaveAttribute(
|
|
"aria-label",
|
|
"Not managed on this device",
|
|
);
|
|
await expect(relayProvenanceMarker).toHaveAttribute(
|
|
"title",
|
|
"Not managed on this device",
|
|
);
|
|
await expect(relayProvenanceMarker).toBeVisible();
|
|
await expect(relayProvenanceMarker).toHaveText("");
|
|
await expect(relayProvenanceMarker.locator("svg")).toBeVisible();
|
|
await expect(managedRow).not.toContainText("managed by you");
|
|
await expect(relayRow).not.toContainText("managed by you");
|
|
|
|
await page.setViewportSize({ width: 760, height: 640 });
|
|
await expect(relayProvenanceMarker).toBeVisible();
|
|
const rowBox = await relayRow.boundingBox();
|
|
const dropdownBox = await dropdown.boundingBox();
|
|
expect(rowBox).not.toBeNull();
|
|
expect(dropdownBox).not.toBeNull();
|
|
expect((rowBox?.x ?? 0) + (rowBox?.width ?? 0)).toBeLessThanOrEqual(
|
|
(dropdownBox?.x ?? 0) + (dropdownBox?.width ?? 0) + 1,
|
|
);
|
|
|
|
const collisionKeys = dropdown.getByTestId("mention-collision-npub");
|
|
await expect(collisionKeys).toHaveCount(2);
|
|
const fullNpubs = await collisionKeys.evaluateAll((nodes) =>
|
|
nodes.map((node) => node.getAttribute("title")),
|
|
);
|
|
expect(fullNpubs).toHaveLength(2);
|
|
expect(new Set(fullNpubs).size).toBe(2);
|
|
|
|
await managedRow.getByRole("button", { name: "Mention carl" }).click();
|
|
await expect(
|
|
page.getByTestId(`composer-address-lock-${managedPubkey}`),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.getByTestId(`composer-address-lock-${relayPubkey}`),
|
|
).toHaveCount(0);
|
|
await input.pressSequentially("local");
|
|
await page.getByTestId("send-message").click();
|
|
await expect
|
|
.poll(() => readOutgoingMentionPubkeys(page, "@carl local"))
|
|
.toEqual([managedPubkey]);
|
|
await expect(input).toHaveText("");
|
|
|
|
await input.fill("@carl");
|
|
const reopenedDropdown = autocomplete(page);
|
|
await expect(reopenedDropdown).toBeVisible();
|
|
const reopenedRelayRow = reopenedDropdown.getByTestId(
|
|
`mention-suggestion-${relayPubkey}`,
|
|
);
|
|
await reopenedRelayRow.getByRole("button", { name: "Mention carl" }).click();
|
|
await expect(
|
|
page.getByTestId(`composer-address-lock-${relayPubkey}`),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.getByTestId(`composer-address-lock-${managedPubkey}`),
|
|
).toHaveCount(0);
|
|
await input.pressSequentially("remote");
|
|
await page.getByTestId("send-message").click();
|
|
const sendWithoutInviting = page.getByRole("button", { name: "Do nothing" });
|
|
try {
|
|
await sendWithoutInviting.waitFor({ state: "visible", timeout: 2_000 });
|
|
await sendWithoutInviting.click();
|
|
} catch {
|
|
// In-channel selections send immediately without opening the prompt.
|
|
}
|
|
await expect
|
|
// Sending the first root message clears its draft-local label reservation.
|
|
.poll(() => readOutgoingMentionPubkeys(page, "@carl remote"))
|
|
.toEqual([relayPubkey]);
|
|
|
|
await page.getByTestId("channel-members-trigger").click();
|
|
const remoteSidebarMarker = page.getByTestId(
|
|
`sidebar-member-agent-provenance-${relayPubkey}`,
|
|
);
|
|
await expect(
|
|
page.getByTestId(`sidebar-member-agent-provenance-${managedPubkey}`),
|
|
).toHaveCount(0);
|
|
await expect(remoteSidebarMarker).toHaveAttribute(
|
|
"aria-label",
|
|
"Not managed on this device",
|
|
);
|
|
await expect(remoteSidebarMarker).toHaveText("");
|
|
await expect(remoteSidebarMarker.locator("svg")).toBeVisible();
|
|
const remoteSidebarRow = page.getByTestId(`sidebar-member-${relayPubkey}`);
|
|
const localSidebarMarker = page.getByTestId(
|
|
`sidebar-member-agent-provenance-${managedPubkey}`,
|
|
);
|
|
const markerRemainsVisible = () =>
|
|
remoteSidebarMarker.evaluate((marker) => {
|
|
let element: Element | null = marker;
|
|
while (element) {
|
|
const style = window.getComputedStyle(element);
|
|
if (style.display === "none" || style.visibility === "hidden") {
|
|
return false;
|
|
}
|
|
if (Number(style.opacity) === 0) return false;
|
|
element = element.parentElement;
|
|
}
|
|
return true;
|
|
});
|
|
await expect.poll(markerRemainsVisible).toBe(true);
|
|
await expect(localSidebarMarker).toHaveCount(0);
|
|
const sidebarMarkerBox = await remoteSidebarMarker.boundingBox();
|
|
const sidebarBox = await page.getByTestId("members-sidebar").boundingBox();
|
|
expect(sidebarMarkerBox).not.toBeNull();
|
|
expect(sidebarBox).not.toBeNull();
|
|
expect(
|
|
(sidebarMarkerBox?.x ?? 0) + (sidebarMarkerBox?.width ?? 0),
|
|
).toBeLessThanOrEqual((sidebarBox?.x ?? 0) + (sidebarBox?.width ?? 0) + 1);
|
|
|
|
await remoteSidebarRow.hover();
|
|
await page.waitForTimeout(200);
|
|
await expect.poll(markerRemainsVisible).toBe(true);
|
|
await expect(localSidebarMarker).toHaveCount(0);
|
|
|
|
await page.getByTestId(`sidebar-member-open-profile-${relayPubkey}`).focus();
|
|
await page.waitForTimeout(200);
|
|
await expect.poll(markerRemainsVisible).toBe(true);
|
|
await expect(localSidebarMarker).toHaveCount(0);
|
|
});
|
|
|
|
test("relay-only shared agents emit an outbound mention tag when selected", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Ask @alice");
|
|
|
|
const aliceRow = autocomplete(page).locator("button", { hasText: "alice" });
|
|
await expect(aliceRow).toBeVisible();
|
|
await aliceRow.click();
|
|
await page.keyboard.type("please reply");
|
|
|
|
const content = "Ask @alice please reply";
|
|
await expect(input).toHaveText(content);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
await expect
|
|
.poll(() => readOutgoingMentionPubkeys(page, content))
|
|
.toContain(TEST_IDENTITIES.alice.pubkey);
|
|
});
|
|
|
|
test("typing an exact agent name and Space commits its chip and mention tag", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@alice");
|
|
await expect(
|
|
autocomplete(page).getByTestId(
|
|
`mention-suggestion-${TEST_IDENTITIES.alice.pubkey}`,
|
|
),
|
|
).toBeVisible();
|
|
|
|
await input.fill("Ask @alice");
|
|
await input.press(" ");
|
|
await page.keyboard.type("please reply");
|
|
|
|
const content = "Ask @alice please reply";
|
|
await expect(input).toHaveText(content);
|
|
await expect(
|
|
input.locator(".agent-mention-highlight", { hasText: "alice" }),
|
|
).toBeVisible();
|
|
|
|
await page.getByTestId("send-message").click();
|
|
await expect
|
|
.poll(() => readOutgoingMentionPubkeys(page, content))
|
|
.toContain(TEST_IDENTITIES.alice.pubkey);
|
|
});
|
|
|
|
test("Shift+Space leaves an exact agent name plain and emits no mention tag", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY,
|
|
name: "quinn",
|
|
status: "stopped",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Plain in-channel member names are intentionally tagged at send time, so
|
|
// use an authorized non-member to isolate selection from text extraction.
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@quinn");
|
|
await expect(
|
|
autocomplete(page).getByTestId(
|
|
`mention-suggestion-${OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY}`,
|
|
),
|
|
).toBeVisible();
|
|
|
|
await input.fill("Ask @quinn");
|
|
await input.press("Shift+Space");
|
|
await page.keyboard.type("please reply");
|
|
|
|
const content = "Ask @quinn please reply";
|
|
await expect(input).toHaveText(content);
|
|
await expect(input.locator(".agent-mention-highlight")).toHaveCount(0);
|
|
|
|
await page.getByTestId("send-message").click();
|
|
await expect
|
|
.poll(() => readOutgoingMentionPubkeys(page, content))
|
|
.toEqual([]);
|
|
});
|
|
|
|
// The three tests below pin the code-context gate on the Space commit. They
|
|
// key off casing, because a commit rewrites the draft to the candidate's
|
|
// canonical display name: a surviving "@ALICE" means the typed text was left
|
|
// alone. Chip decorations are deliberately not asserted — they already render
|
|
// over known names inside code, which is a separate pre-existing gap.
|
|
test("Space inside a code block leaves an exact agent name literal", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.click();
|
|
await page.keyboard.type("```");
|
|
await page.keyboard.press("Enter");
|
|
await expect(input.locator("pre")).toBeVisible();
|
|
|
|
await page.keyboard.type("deploy @ALICE");
|
|
await page.keyboard.press(" ");
|
|
await page.keyboard.type("now");
|
|
|
|
await expect(input.locator("pre")).toHaveText("deploy @ALICE now");
|
|
|
|
await page.getByTestId("send-message").click();
|
|
await expect
|
|
.poll(() => readOutgoingMentionPubkeys(page, "```\ndeploy @ALICE now\n```"))
|
|
.toEqual([]);
|
|
});
|
|
|
|
test("Space inside an inline code span leaves an exact agent name literal", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.click();
|
|
// The closing backtick turns the span into a code mark, which drops the
|
|
// backticks from the text the mention pipeline reads.
|
|
await page.keyboard.type("run `@ALICE`");
|
|
await expect(input.locator("code")).toHaveText("@ALICE");
|
|
|
|
await page.keyboard.press(" ");
|
|
await page.keyboard.type("now");
|
|
|
|
await expect(input.locator("code")).toHaveText("@ALICE");
|
|
await expect(input).toHaveText("run @ALICE now");
|
|
|
|
await page.getByTestId("send-message").click();
|
|
await expect
|
|
.poll(() => readOutgoingMentionPubkeys(page, "run `@ALICE` now"))
|
|
.toEqual([]);
|
|
});
|
|
|
|
test("Space still resolves an exact agent name typed after a code span", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.click();
|
|
await page.keyboard.type("run `deploy` @ALICE");
|
|
await page.keyboard.press(" ");
|
|
await page.keyboard.type("now");
|
|
|
|
const content = "run `deploy` @alice now";
|
|
await expect(input).toHaveText("run deploy @alice now");
|
|
|
|
await page.getByTestId("send-message").click();
|
|
await expect
|
|
.poll(() => readOutgoingMentionPubkeys(page, content))
|
|
.toContain(TEST_IDENTITIES.alice.pubkey);
|
|
});
|
|
|
|
test("thread autocomplete keeps multiple long names readable in a narrow panel", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey:
|
|
"9999999999999999999999999999999999999999999999999999999999999999",
|
|
name: "Brain With A Very Long Name",
|
|
status: "stopped",
|
|
},
|
|
{
|
|
pubkey:
|
|
"9999999999999999999999999999999999999999999999999999999999999998",
|
|
name: "Brainstorming Assistant With A Long Name",
|
|
status: "stopped",
|
|
},
|
|
{
|
|
pubkey:
|
|
"9999999999999999999999999999999999999999999999999999999999999997",
|
|
name: "Brainy Helper With Another Long Name",
|
|
status: "stopped",
|
|
},
|
|
],
|
|
});
|
|
await page.setViewportSize({ width: 900, height: 640 });
|
|
await page.addInitScript(() => {
|
|
window.sessionStorage.setItem("buzz.desktop.thread-panel-width", "300");
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await page.setViewportSize({ width: 760, height: 640 });
|
|
|
|
await emitMockMessage(page, "general", "Reply to open the thread", {
|
|
parentEventId: "mock-general-welcome",
|
|
});
|
|
const threadSummary = page.getByTestId("message-thread-summary").first();
|
|
await expect(threadSummary).toBeVisible();
|
|
await threadSummary.click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
await expect(threadPanel).toBeVisible();
|
|
const panelBox = await threadPanel.boundingBox();
|
|
expect(panelBox?.width ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual(320);
|
|
|
|
const input = threadPanel.getByTestId("message-input");
|
|
await input.fill("@Brain");
|
|
|
|
const dropdown = threadPanel.getByTestId("mention-autocomplete");
|
|
await expect(dropdown).toBeVisible();
|
|
|
|
for (const name of [
|
|
"Brain With A Very Long Name",
|
|
"Brainstorming Assistant With A Long Name",
|
|
"Brainy Helper With Another Long Name",
|
|
]) {
|
|
const row = dropdown.locator("button", { hasText: name });
|
|
await expect(row).toBeVisible();
|
|
await expect(
|
|
row.getByTestId("mention-suggestion-avatar-fallback"),
|
|
).toBeVisible();
|
|
await expect(row.getByText("agent")).toBeVisible();
|
|
await expect(row.getByText("managed by you")).toBeVisible();
|
|
|
|
await expect(row.getByText(name)).not.toHaveCSS(
|
|
"text-overflow",
|
|
"ellipsis",
|
|
);
|
|
}
|
|
});
|
|
|
|
test("blocks non-participant persona mentions in DM threads", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
activePersonaIds: ["builtin:fizz"],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-bob-tyler").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler");
|
|
await waitForMockLiveSubscription(page, "bob-tyler");
|
|
|
|
const threadRoot = await emitMockMessage(
|
|
page,
|
|
"bob-tyler",
|
|
"Thread before adding an agent",
|
|
);
|
|
await emitMockMessage(page, "bob-tyler", "Existing thread reply", {
|
|
parentEventId: threadRoot.id,
|
|
});
|
|
const threadSummary = page.getByTestId("message-thread-summary").first();
|
|
await expect(threadSummary).toBeVisible();
|
|
await threadSummary.click();
|
|
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const input = threadPanel.getByTestId("message-input");
|
|
await input.fill("Ask @fi");
|
|
await expect(
|
|
threadPanel
|
|
.getByTestId("mention-autocomplete")
|
|
.locator("button", { hasText: "Fizz" }),
|
|
).toBeVisible();
|
|
await input.press("Enter");
|
|
await page.keyboard.type(" in this thread");
|
|
const baselineCommands = await readCommandLog(page);
|
|
|
|
await threadPanel.getByTestId("send-message").click();
|
|
|
|
await expect(
|
|
page.getByText(
|
|
"Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent.",
|
|
),
|
|
).toBeVisible();
|
|
const commands = await readCommandLog(page);
|
|
expect(commandCount(commands, "create_managed_agent")).toBe(
|
|
commandCount(baselineCommands, "create_managed_agent"),
|
|
);
|
|
expect(commandCount(commands, "add_channel_members")).toBe(
|
|
commandCount(baselineCommands, "add_channel_members"),
|
|
);
|
|
await expect(input).toContainText("Fizz");
|
|
await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler");
|
|
});
|
|
|
|
test("defers agent mentions until DM members finish loading", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
channelMembersReadDelayMs: 5_000,
|
|
managedAgents: [
|
|
{
|
|
pubkey: TEST_IDENTITIES.alice.pubkey,
|
|
name: "alice",
|
|
status: "stopped",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-alice-tyler").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
|
|
await waitForMockLiveSubscription(page, "alice-tyler");
|
|
|
|
const threadRoot = await emitMockMessage(
|
|
page,
|
|
"alice-tyler",
|
|
"Thread while members load",
|
|
);
|
|
await emitMockMessage(page, "alice-tyler", "Existing thread reply", {
|
|
parentEventId: threadRoot.id,
|
|
});
|
|
await page.getByTestId("message-thread-summary").first().click();
|
|
|
|
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");
|
|
const baselineCommands = await readCommandLog(page);
|
|
await threadPanel.getByTestId("send-message").click();
|
|
|
|
await expect(
|
|
page.getByText(DM_THREAD_MEMBERS_LOADING_ERROR_TEXT).first(),
|
|
).toBeVisible();
|
|
await page.mouse.move(0, 0);
|
|
expect(commandCount(await readCommandLog(page), "add_channel_members")).toBe(
|
|
commandCount(baselineCommands, "add_channel_members"),
|
|
);
|
|
await expect(input).toContainText("before members resolve");
|
|
|
|
await page.waitForTimeout(5_100);
|
|
await threadPanel.getByTestId("send-message").click();
|
|
|
|
await expect(page.getByText(DM_THREAD_AGENT_MENTION_ERROR_TEXT)).toHaveCount(
|
|
0,
|
|
);
|
|
expect(commandCount(await readCommandLog(page), "add_channel_members")).toBe(
|
|
commandCount(baselineCommands, "add_channel_members"),
|
|
);
|
|
await expect(input).toHaveText("@alice ");
|
|
await expect(threadPanel).toContainText("before members resolve");
|
|
});
|
|
|
|
test("autocomplete filters managed-agent suggestions as user types", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: TEST_IDENTITIES.alice.pubkey,
|
|
name: "alice",
|
|
status: "stopped",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@ali");
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown.getByText("alice")).toBeVisible();
|
|
await expect(dropdown.getByText("bob")).not.toBeVisible();
|
|
});
|
|
|
|
test("autocomplete searches global non-member people from the first typed character", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
searchProfiles: [
|
|
{
|
|
pubkey: CASEY_PROFILE_PUBKEY,
|
|
displayName: "tessa",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@t");
|
|
|
|
const dropdown = autocomplete(page);
|
|
const tessaRow = dropdown.locator("button", { hasText: "tessa" });
|
|
await expect(tessaRow).toBeVisible();
|
|
await expect(tessaRow.getByText("not in channel")).toBeVisible();
|
|
});
|
|
|
|
test("mention autocomplete caps global people search at 50 results", async ({
|
|
page,
|
|
}) => {
|
|
const searchProfiles = Array.from({ length: 55 }, (_, index) => ({
|
|
pubkey: `${(index + 1).toString(16).padStart(64, "0")}`,
|
|
displayName: `Alex ${String(index + 1).padStart(2, "0")}`,
|
|
}));
|
|
await installMockBridge(page, { searchProfiles });
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await page.getByTestId("message-input").fill("@Alex");
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown.locator("button")).toHaveCount(50);
|
|
await expect(dropdown.getByText("Alex 50")).toBeVisible();
|
|
await expect(dropdown.getByText("Alex 55")).toHaveCount(0);
|
|
await expect(dropdown.getByText("not in channel").last()).toBeVisible();
|
|
|
|
const searchCalls = (await readCommandPayloadLog(page)).filter(
|
|
(entry) => entry.command === "search_users",
|
|
);
|
|
expect(searchCalls).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
payload: expect.objectContaining({ cursor: null, limit: 50 }),
|
|
}),
|
|
]),
|
|
);
|
|
expect(searchCalls).not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
payload: expect.objectContaining({ cursor: "2", limit: 50 }),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
test("selecting a person mention inserts @Name into input", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
window.localStorage.setItem("buzz-theme", "buzz-dark");
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
const dropdown = autocomplete(page);
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
await input.fill("Hey @bo");
|
|
await dropdown.getByText("bob").click();
|
|
await page.keyboard.type("hello");
|
|
await expect(input).toHaveText("Hey @bob hello");
|
|
}
|
|
const mentionChip = input.locator(".human-mention-highlight", {
|
|
hasText: "bob",
|
|
});
|
|
await expect(mentionChip).toBeVisible();
|
|
await expect(mentionChip).toHaveText("bob");
|
|
await expect(mentionChip).not.toHaveClass(/agent-mention-highlight/);
|
|
await expect(mentionChip).toHaveCSS("display", "inline");
|
|
await expect(
|
|
input.locator(".mention-prefix-hidden", { hasText: "@" }),
|
|
).toHaveCount(1);
|
|
const iconMask = await mentionChip.evaluate((element) =>
|
|
getComputedStyle(element, "::before").getPropertyValue(
|
|
"-webkit-mask-image",
|
|
),
|
|
);
|
|
expect(iconMask).toContain("data:image/svg+xml");
|
|
expect(
|
|
await mentionChip.evaluate(
|
|
(element) => getComputedStyle(element, "::before").display,
|
|
),
|
|
).toBe("inline-block");
|
|
await expect(
|
|
input.locator(".mention-prefix-hidden", { hasText: "@" }),
|
|
).toHaveCSS("opacity", "0");
|
|
await expect(mentionChip).toHaveCSS("line-height", "18px");
|
|
const scrollViewport = page.getByTestId("message-input-scroll");
|
|
const paintedBounds = await mentionChip.evaluate((element) => {
|
|
const chip = element.getBoundingClientRect();
|
|
const viewport = element
|
|
.closest("[data-testid='message-input-scroll']")
|
|
?.getBoundingClientRect();
|
|
if (!viewport)
|
|
throw new Error("Mention chip is missing its scroll viewport");
|
|
return {
|
|
chipTop: chip.top,
|
|
chipBottom: chip.bottom,
|
|
viewportTop: viewport.top,
|
|
viewportBottom: viewport.bottom,
|
|
};
|
|
});
|
|
expect(paintedBounds.chipTop).toBeGreaterThanOrEqual(
|
|
paintedBounds.viewportTop,
|
|
);
|
|
expect(paintedBounds.chipBottom).toBeLessThanOrEqual(
|
|
paintedBounds.viewportBottom,
|
|
);
|
|
const humanIconTranslateY = await mentionChip.evaluate(
|
|
(element) =>
|
|
new DOMMatrix(getComputedStyle(element, "::before").transform).m42,
|
|
);
|
|
const channelIconTranslateY = await input.evaluate((composer) => {
|
|
const probe = document.createElement("span");
|
|
probe.className =
|
|
"mention-chip inline-chip-with-icon inline-chip-icon-channel";
|
|
composer.append(probe);
|
|
const translateY = new DOMMatrix(
|
|
getComputedStyle(probe, "::before").transform,
|
|
).m42;
|
|
probe.remove();
|
|
return translateY;
|
|
});
|
|
expect(humanIconTranslateY - channelIconTranslateY).toBeCloseTo(1);
|
|
|
|
await input.fill("@bo");
|
|
await dropdown.getByText("bob").click();
|
|
await input.press("Shift+Enter");
|
|
await page.keyboard.type("@bo");
|
|
await dropdown.getByText("bob").click();
|
|
await input.press("Shift+Enter");
|
|
await page.keyboard.type("@bo");
|
|
await dropdown.getByText("bob").click();
|
|
const multilineChips = input.locator(".human-mention-highlight");
|
|
await expect(multilineChips).toHaveCount(3);
|
|
const multilinePaintBounds = await multilineChips.evaluateAll((elements) => {
|
|
const viewport = elements[0]
|
|
?.closest("[data-testid='message-input-scroll']")
|
|
?.getBoundingClientRect();
|
|
if (!viewport) {
|
|
throw new Error("Mention chips are missing their scroll viewport");
|
|
}
|
|
return elements.map((element) => {
|
|
const chip = element.getBoundingClientRect();
|
|
return {
|
|
chipTop: chip.top,
|
|
chipBottom: chip.bottom,
|
|
viewportTop: viewport.top,
|
|
viewportBottom: viewport.bottom,
|
|
};
|
|
});
|
|
});
|
|
for (const bounds of multilinePaintBounds) {
|
|
expect(bounds.chipTop).toBeGreaterThanOrEqual(bounds.viewportTop);
|
|
expect(bounds.chipBottom).toBeLessThanOrEqual(bounds.viewportBottom);
|
|
}
|
|
|
|
await scrollViewport.evaluate((element) => {
|
|
element.style.width = "8rem";
|
|
});
|
|
await input.fill("A deliberately long prefix that forces @bo");
|
|
await dropdown.getByText("bob").click();
|
|
const wrappedChip = input.locator(".human-mention-highlight", {
|
|
hasText: "bob",
|
|
});
|
|
const wrappedPaintBounds = await wrappedChip.evaluate((element) => {
|
|
const chip = element.getBoundingClientRect();
|
|
const viewport = element
|
|
.closest("[data-testid='message-input-scroll']")
|
|
?.getBoundingClientRect();
|
|
if (!viewport)
|
|
throw new Error("Mention chip is missing its scroll viewport");
|
|
return {
|
|
chipTop: chip.top,
|
|
chipBottom: chip.bottom,
|
|
viewportTop: viewport.top,
|
|
viewportBottom: viewport.bottom,
|
|
};
|
|
});
|
|
expect(wrappedPaintBounds.chipTop).toBeGreaterThanOrEqual(
|
|
wrappedPaintBounds.viewportTop,
|
|
);
|
|
expect(wrappedPaintBounds.chipBottom).toBeLessThanOrEqual(
|
|
wrappedPaintBounds.viewportBottom,
|
|
);
|
|
await expect(input).toHaveCSS("height", /^(?!20px$)/);
|
|
await scrollViewport.evaluate((element) => {
|
|
element.style.removeProperty("width");
|
|
});
|
|
|
|
await waitForAnimations(page);
|
|
await page.getByTestId("message-composer").screenshot({
|
|
path: "test-results/inline-chip-polish/composer-after.png",
|
|
});
|
|
});
|
|
|
|
test("immediate ArrowLeft after a person mention is not bounced past the trailing space", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Hey @bo");
|
|
await autocomplete(page).getByText("bob").click();
|
|
await page.keyboard.press("ArrowLeft");
|
|
await page.keyboard.type("x");
|
|
const text = (await input.innerText()).replace(/\s+$/, "");
|
|
expect(text).toBe("Hey @bobx");
|
|
expect(text).not.toMatch(/@bob x/);
|
|
});
|
|
|
|
test("clicking a person mention chip edge is not treated as after the trailing space", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Hey @bo");
|
|
await autocomplete(page).getByText("bob").click();
|
|
const chip = input.locator(".human-mention-highlight", { hasText: "bob" });
|
|
await expect(chip).toBeVisible();
|
|
const box = await chip.boundingBox();
|
|
expect(box).toBeTruthy();
|
|
await chip.click({
|
|
position: {
|
|
x: Math.max((box?.width ?? 1) - 2, 0),
|
|
y: (box?.height ?? 2) / 2,
|
|
},
|
|
});
|
|
await page.keyboard.type("x");
|
|
const text = (await input.innerText()).replace(/\s+$/, "");
|
|
expect(text).toBe("Hey @bobx");
|
|
expect(text).not.toMatch(/@bob x/);
|
|
});
|
|
|
|
test("typing a mention before existing text does not interleave spaces", async ({
|
|
page,
|
|
}) => {
|
|
// Regression (the reported repro): with a draft already written, place the
|
|
// caret earlier in the message, type a partial mention, pick a suggestion,
|
|
// then keep typing. Caret correction used to fire on every document change
|
|
// and walk the caret across the mention's trailing space, so each keystroke
|
|
// pushed a space further into the rest of the draft.
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("hello world");
|
|
|
|
// Click between "hello" and " world", then open autocomplete there.
|
|
await input.focus();
|
|
for (let i = 0; i < " world".length; i++) {
|
|
await page.keyboard.press("ArrowLeft");
|
|
}
|
|
await page.keyboard.type(" @bo");
|
|
await autocomplete(page).getByText("bob").click();
|
|
await page.keyboard.type("abc");
|
|
|
|
await expect(input).toHaveText("hello @bob abc world");
|
|
});
|
|
|
|
test("typing an unregistered @token before existing text is left alone", async ({
|
|
page,
|
|
}) => {
|
|
// The trailing-space scan is purely textual, so it also fired for tokens
|
|
// that were never registered as mentions.
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("hello world");
|
|
await input.focus();
|
|
for (let i = 0; i < " world".length; i++) {
|
|
await page.keyboard.press("ArrowLeft");
|
|
}
|
|
await page.keyboard.type(" @zzq");
|
|
|
|
await expect(input).toHaveText("hello @zzq world");
|
|
});
|
|
|
|
test("wrapped channel references keep the icon on the first composer line", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await page.getByTestId("message-input-scroll").evaluate((element) => {
|
|
element.style.width = "74px";
|
|
});
|
|
await input.fill("#all-replies");
|
|
|
|
const channelChip = input.locator(".inline-chip-icon-channel", {
|
|
hasText: "all-replies",
|
|
});
|
|
await expect(channelChip).toBeVisible();
|
|
// CSSOM exposes pseudo-element styles but not their rendered box.
|
|
const cdp = await page.context().newCDPSession(page);
|
|
const { root } = await cdp.send("DOM.getDocument", {
|
|
depth: -1,
|
|
pierce: true,
|
|
});
|
|
const { nodeId } = await cdp.send("DOM.querySelector", {
|
|
nodeId: root.nodeId,
|
|
selector: ".rich-text-composer .inline-chip-icon-channel",
|
|
});
|
|
const { node } = await cdp.send("DOM.describeNode", { nodeId, depth: 1 });
|
|
const before = node.pseudoElements?.find(
|
|
(pseudo) => pseudo.pseudoType === "before",
|
|
);
|
|
if (!before) {
|
|
throw new Error("Channel chip is missing its generated icon");
|
|
}
|
|
const iconBox = await cdp.send("DOM.getBoxModel", { nodeId: before.nodeId });
|
|
const iconTop = iconBox.model.border[1];
|
|
await cdp.detach();
|
|
const geometry = await channelChip.evaluate((element) => {
|
|
const textNode = element.firstChild;
|
|
if (!(textNode instanceof Text)) {
|
|
throw new Error("Channel chip is missing its decorated text node");
|
|
}
|
|
const textRange = document.createRange();
|
|
textRange.selectNodeContents(textNode);
|
|
const rects = (source: DOMRectList) =>
|
|
Array.from(source, (rect) => ({
|
|
left: rect.left,
|
|
top: rect.top,
|
|
}));
|
|
const iconStyle = getComputedStyle(element, "::before");
|
|
const tokenProbe = document.createElement("span");
|
|
tokenProbe.style.cssText =
|
|
"position:fixed;width:var(--inline-chip-padding-inline)";
|
|
element.append(tokenProbe);
|
|
const tokenPadding = tokenProbe.getBoundingClientRect().width;
|
|
tokenProbe.remove();
|
|
return {
|
|
chipRects: rects(element.getClientRects()),
|
|
iconPosition: iconStyle.position,
|
|
iconTransform: iconStyle.transform,
|
|
tokenPadding,
|
|
textRects: rects(textRange.getClientRects()),
|
|
};
|
|
});
|
|
expect(geometry.chipRects).toHaveLength(2);
|
|
expect(geometry.textRects).toHaveLength(2);
|
|
expect(geometry.iconPosition).toBe("static");
|
|
expect(geometry.iconTransform).toBe("none");
|
|
expect(
|
|
geometry.textRects[0].left - geometry.chipRects[0].left,
|
|
).toBeGreaterThan(geometry.tokenPadding);
|
|
expect(geometry.textRects[1].left - geometry.chipRects[1].left).toBeCloseTo(
|
|
geometry.tokenPadding,
|
|
0,
|
|
);
|
|
expect(iconTop - geometry.textRects[0].top).toBeCloseTo(2.5, 0);
|
|
});
|
|
|
|
test("channel references keep caret movement through the channel name", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("#general");
|
|
|
|
const channelChip = input.locator(".inline-chip-icon-channel", {
|
|
hasText: "general",
|
|
});
|
|
await expect(channelChip).toBeVisible();
|
|
await expect(channelChip).toHaveText("general");
|
|
await expect(
|
|
input.locator(".mention-prefix-hidden", { hasText: "#" }),
|
|
).toHaveCount(1);
|
|
const iconMask = await channelChip.evaluate((element) =>
|
|
getComputedStyle(element, "::before").getPropertyValue(
|
|
"-webkit-mask-image",
|
|
),
|
|
);
|
|
expect(iconMask).toContain("data:image/svg+xml");
|
|
expect(
|
|
await channelChip.evaluate(
|
|
(element) => getComputedStyle(element, "::before").display,
|
|
),
|
|
).toBe("inline-block");
|
|
await expect(
|
|
input.locator(".mention-prefix-hidden", { hasText: "#" }),
|
|
).toHaveCSS("opacity", "0");
|
|
|
|
await input.focus();
|
|
await input.press("ArrowLeft");
|
|
await input.press("ArrowLeft");
|
|
await input.press("ArrowLeft");
|
|
await page.keyboard.type("X");
|
|
|
|
await expect(input).toHaveText("#geneXral");
|
|
});
|
|
|
|
test("selecting a managed agent mention inserts @Name into input", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: TEST_IDENTITIES.alice.pubkey,
|
|
name: "alice",
|
|
status: "stopped",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
const dropdown = autocomplete(page);
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
await input.fill("Hey @ali");
|
|
await dropdown.getByText("alice").click();
|
|
await page.keyboard.type("hello");
|
|
await expect(input).toHaveText("Hey @alice hello");
|
|
}
|
|
const agentMentionChip = input.locator(".agent-mention-highlight", {
|
|
hasText: "alice",
|
|
});
|
|
await expect(agentMentionChip).toBeVisible();
|
|
await expect(agentMentionChip).toHaveText("alice");
|
|
await expect(agentMentionChip).toHaveCSS("display", "inline");
|
|
await expect(agentMentionChip).toHaveCSS("border-top-width", "0px");
|
|
expect(
|
|
await agentMentionChip.evaluate(
|
|
(element) => getComputedStyle(element, "::before").display,
|
|
),
|
|
).toBe("inline-block");
|
|
await expect(
|
|
input.locator(".mention-prefix-hidden", { hasText: "@" }),
|
|
).toHaveCSS("opacity", "0");
|
|
});
|
|
|
|
test("selecting a persona mention creates a channel agent before sending and starts it detached", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
activePersonaIds: ["builtin:fizz"],
|
|
// Far longer than the test runs: sign_event landing below proves the
|
|
// publish no longer waits for start_managed_agent to resolve.
|
|
startManagedAgentDelayMs: 45_000,
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Ask @fi");
|
|
|
|
const dropdown = autocomplete(page);
|
|
const fizzRow = dropdown.locator("button", { hasText: "Fizz" });
|
|
await expect(fizzRow).toBeVisible();
|
|
await expect(fizzRow.getByTestId("mention-agent-icon")).toBeVisible();
|
|
await expect(fizzRow.getByText("agent")).toBeVisible();
|
|
await expect(fizzRow.getByText("not in channel")).toBeVisible();
|
|
await input.press("Enter");
|
|
await page.keyboard.type(" for a hand");
|
|
|
|
const composerChip = input.locator(".agent-mention-highlight", {
|
|
hasText: "Fizz",
|
|
});
|
|
await expect(composerChip).toBeVisible();
|
|
await expect(composerChip).toHaveText("Fizz");
|
|
|
|
const baselineCommands = await readCommandLog(page);
|
|
const baselineCreateCount = commandCount(
|
|
baselineCommands,
|
|
"create_managed_agent",
|
|
);
|
|
const baselineAddCount = commandCount(
|
|
baselineCommands,
|
|
"add_channel_members",
|
|
);
|
|
const baselineStartCount = commandCount(
|
|
baselineCommands,
|
|
"start_managed_agent",
|
|
);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
|
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "create_managed_agent"),
|
|
)
|
|
.toBeGreaterThan(baselineCreateCount);
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "add_channel_members"),
|
|
)
|
|
.toBeGreaterThan(baselineAddCount);
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "start_managed_agent"),
|
|
)
|
|
.toBeGreaterThan(baselineStartCount);
|
|
await expect
|
|
.poll(async () => commandCount(await readCommandLog(page), "sign_event"))
|
|
.toBeGreaterThan(commandCount(baselineCommands, "sign_event"));
|
|
|
|
const commandsAfterSend = (await readCommandLog(page)).slice(
|
|
baselineCommands.length,
|
|
);
|
|
const createIndex = commandsAfterSend.indexOf("create_managed_agent");
|
|
const addIndex = commandsAfterSend.indexOf("add_channel_members");
|
|
const sendIndex = commandsAfterSend.indexOf("sign_event");
|
|
expect(createIndex).toBeGreaterThanOrEqual(0);
|
|
expect(addIndex).toBeGreaterThanOrEqual(0);
|
|
expect(sendIndex).toBeGreaterThanOrEqual(0);
|
|
// Publish-first: creation and the membership write still precede the
|
|
// publish (the outgoing tags need the agent's pubkey, and the harness only
|
|
// subscribes to channels it is a member of), but the start is detached —
|
|
// sign_event landed while start_managed_agent was still pending behind the
|
|
// injected 45s delay, which the old start-blocking send could never do.
|
|
expect(createIndex).toBeLessThan(sendIndex);
|
|
expect(addIndex).toBeLessThan(sendIndex);
|
|
|
|
const mentionChip = page
|
|
.getByTestId("message-row")
|
|
.last()
|
|
.locator("[data-mention].agent-mention-highlight", { hasText: "Fizz" });
|
|
await expect(mentionChip).toBeVisible();
|
|
await expect(mentionChip).toHaveText("Fizz");
|
|
await expect(mentionChip).toHaveClass(/wrapping-inline-chip/);
|
|
const timelineLayout = await timelineChipLayout(mentionChip);
|
|
expect(timelineLayout).toMatchObject({
|
|
boxDecorationBreak: "clone",
|
|
chipHeight: 17,
|
|
chipLineHeight: 18,
|
|
fragmentCount: 1,
|
|
fragmentGap: null,
|
|
fragmentHeight: 17,
|
|
fragmentStep: null,
|
|
paragraphHeight: 20,
|
|
paragraphLineHeight: 20,
|
|
});
|
|
});
|
|
|
|
test("selecting a persona mention reuses an existing persona agent", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
activePersonaIds: ["builtin:fizz"],
|
|
managedAgents: [
|
|
{
|
|
pubkey: REUSABLE_PERSONA_AGENT_PUBKEY,
|
|
name: "Fizz",
|
|
personaId: "builtin:fizz",
|
|
status: "stopped",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Ask @fi");
|
|
|
|
const dropdown = autocomplete(page);
|
|
const fizzRow = dropdown.locator("button", { hasText: "Fizz" });
|
|
await expect(fizzRow).toBeVisible();
|
|
await input.press("Enter");
|
|
await page.keyboard.type(" for a hand");
|
|
|
|
const baselineCommands = await readCommandLog(page);
|
|
const baselineCreateCount = commandCount(
|
|
baselineCommands,
|
|
"create_managed_agent",
|
|
);
|
|
const baselineAddCount = commandCount(
|
|
baselineCommands,
|
|
"add_channel_members",
|
|
);
|
|
const baselineStartCount = commandCount(
|
|
baselineCommands,
|
|
"start_managed_agent",
|
|
);
|
|
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
|
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "add_channel_members"),
|
|
)
|
|
.toBeGreaterThan(baselineAddCount);
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "start_managed_agent"),
|
|
)
|
|
.toBeGreaterThan(baselineStartCount);
|
|
expect(
|
|
commandCount(await readCommandLog(page), "create_managed_agent"),
|
|
).toEqual(baselineCreateCount);
|
|
|
|
const mentionChip = page
|
|
.getByTestId("message-row")
|
|
.last()
|
|
.locator("[data-mention].agent-mention-highlight", { hasText: "Fizz" });
|
|
await expect(mentionChip).toBeVisible();
|
|
await expect(mentionChip).toHaveText("Fizz");
|
|
});
|
|
|
|
test("managed relay-profile agents with member roles can be addressed explicitly", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: TEST_IDENTITIES.charlie.pubkey,
|
|
name: "charlie",
|
|
status: "stopped",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
|
|
await openChannelBrowser(page);
|
|
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
|
await page
|
|
.getByTestId("browse-channel-sales")
|
|
.getByRole("button", { name: "Join" })
|
|
.click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("sales");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@char");
|
|
|
|
const dropdown = autocomplete(page);
|
|
const charlieRow = dropdown.getByTestId(
|
|
`mention-suggestion-${TEST_IDENTITIES.charlie.pubkey}`,
|
|
);
|
|
await expect(charlieRow.getByText("charlie")).toBeVisible();
|
|
await expect(charlieRow.getByText("agent")).toBeVisible();
|
|
await charlieRow
|
|
.getByRole("button", {
|
|
name: "Mention charlie",
|
|
exact: true,
|
|
})
|
|
.click();
|
|
|
|
await expect(input).toHaveText("@charlie ");
|
|
await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie");
|
|
await expect(
|
|
page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("other-owned agents without a shared channel are hidden from mentions", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
searchProfiles: [
|
|
{
|
|
pubkey: PROFILE_ONLY_AGENT_PUBKEY,
|
|
displayName: "mira",
|
|
ownerPubkey: TEST_IDENTITIES.outsider.pubkey,
|
|
isAgent: true,
|
|
},
|
|
],
|
|
userSearchDelayMs: 1_000,
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@mira");
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown).not.toBeVisible();
|
|
await expect(input.locator(".mention-chip")).toHaveCount(0);
|
|
});
|
|
|
|
test("stale channel-member agents absent from managed and relay directories stay hidden", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { userSearchDelayMs: 1_000 });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@mira");
|
|
|
|
await expect(autocomplete(page)).toHaveCount(0);
|
|
});
|
|
|
|
test("managed relay agents are visible in channel mentions regardless of relay policy", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
name: "quinn",
|
|
status: "stopped",
|
|
},
|
|
],
|
|
relayAgents: [
|
|
{
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
name: "quinn",
|
|
respondTo: "allowlist",
|
|
respondToAllowlist: ["deadbeef".repeat(8)],
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@quinn");
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown.getByText("quinn")).toBeVisible();
|
|
await expect(dropdown.getByText("agent")).toBeVisible();
|
|
});
|
|
|
|
test("relay-only shared agents stay hidden from DM mentions", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-alice-tyler").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
|
|
|
|
await page.getByTestId("message-input").fill("@alice");
|
|
|
|
await expect(autocomplete(page)).toHaveCount(0);
|
|
});
|
|
|
|
test("cached relay-agent suggestions are removed when channel authorization disappears", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { userSearchDelayMs: 10_000 });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@alice");
|
|
const aliceSuggestion = autocomplete(page).getByTestId(
|
|
`mention-suggestion-${TEST_IDENTITIES.alice.pubkey}`,
|
|
);
|
|
await expect(aliceSuggestion).toBeVisible();
|
|
await expect
|
|
.poll(async () =>
|
|
(await readCommandPayloadLog(page)).some(
|
|
(entry) =>
|
|
entry.command === "search_users" &&
|
|
(entry.payload as { query?: string }).query === "alice",
|
|
),
|
|
)
|
|
.toBe(true);
|
|
|
|
await page.evaluate(async (channelId) => {
|
|
const bridge = window as Window & {
|
|
__BUZZ_E2E_INVALIDATE_CHANNELS__?: () => Promise<void>;
|
|
__BUZZ_E2E_MUTATE_CHANNEL__?: (opts: {
|
|
channelId: string;
|
|
channelType: null;
|
|
}) => void;
|
|
};
|
|
bridge.__BUZZ_E2E_MUTATE_CHANNEL__?.({ channelId, channelType: null });
|
|
await bridge.__BUZZ_E2E_INVALIDATE_CHANNELS__?.();
|
|
}, GENERAL_CHANNEL_ID);
|
|
|
|
await expect(aliceSuggestion).toHaveCount(0);
|
|
});
|
|
|
|
test("relay-only shared agents appear in forum mentions", async ({ page }) => {
|
|
await installMockBridge(page, {
|
|
relayAgents: [
|
|
{
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
name: "quinn",
|
|
respondTo: "allowlist",
|
|
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
|
|
channelNames: ["watercooler"],
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-watercooler").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
|
|
await page.getByRole("button", { name: "Start a new post..." }).click();
|
|
|
|
await page.getByTestId("message-input").fill("@quinn");
|
|
|
|
await expect(
|
|
page.getByTestId("mention-autocomplete").getByText("quinn"),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("forum sends revalidate relay-agent authorization before signing", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
deferredComposerUploads: true,
|
|
uploadDescriptors: [
|
|
{
|
|
url: `https://mock.relay/media/${"f".repeat(64)}.pdf`,
|
|
sha256: "f".repeat(64),
|
|
size: 12345,
|
|
type: "application/pdf",
|
|
uploaded: Math.floor(Date.now() / 1000),
|
|
filename: "forum-race.pdf",
|
|
},
|
|
],
|
|
relayAgents: [
|
|
{
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
name: "quinn",
|
|
respondTo: "allowlist",
|
|
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
|
|
channelNames: ["watercooler"],
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-watercooler").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
|
|
await page.getByRole("button", { name: "Start a new post..." }).click();
|
|
|
|
await page.evaluate(
|
|
async ({ channelId, pubkey }) => {
|
|
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) throw new Error("Mock bridge is not installed.");
|
|
await invoke("add_channel_members", {
|
|
channelId,
|
|
pubkeys: [pubkey],
|
|
role: "bot",
|
|
});
|
|
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
|
queryKey: ["channels", channelId, "members"],
|
|
});
|
|
},
|
|
{
|
|
channelId: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11",
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
},
|
|
);
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@quinn");
|
|
await page.getByTestId("mention-autocomplete").getByText("quinn").click();
|
|
await page.keyboard.type("hello");
|
|
await page.getByRole("button", { name: "Attach file" }).click();
|
|
const removeAttachment = page.getByRole("button", {
|
|
name: "Remove attachment",
|
|
});
|
|
await expect(removeAttachment).toBeVisible();
|
|
await page.evaluate(() => {
|
|
window.__BUZZ_E2E__.mock ??= {};
|
|
window.__BUZZ_E2E__.mock.agentListDelayMs = 1_000;
|
|
window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(100).fill(
|
|
"mock forum directory revoked before send",
|
|
);
|
|
});
|
|
|
|
await page.getByTestId("send-message").click();
|
|
await expect(input).toHaveAttribute("contenteditable", "false");
|
|
await expect(removeAttachment).toBeDisabled();
|
|
await removeAttachment.evaluate((button: HTMLButtonElement) =>
|
|
button.click(),
|
|
);
|
|
await expect(removeAttachment).toBeVisible();
|
|
await input.focus();
|
|
await page.keyboard.type(" later edit");
|
|
await expect(input).toContainText("@quinn hello");
|
|
await expect(input).not.toContainText("later edit");
|
|
|
|
const outgoingContent = `@quinn hello\n[forum-race.pdf](https://mock.relay/media/${"f".repeat(64)}.pdf)`;
|
|
await expect(
|
|
page.getByText(/Could not authorize a mentioned agent/),
|
|
).toBeVisible();
|
|
await expect(input).toContainText("@quinn hello");
|
|
expect(await readOutgoingMentionPubkeys(page, outgoingContent)).toBeNull();
|
|
});
|
|
|
|
test("managed agents use the channel roster for membership labels", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY,
|
|
name: "carl",
|
|
status: "running",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(channelId) =>
|
|
window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState([
|
|
"channels",
|
|
channelId,
|
|
"members",
|
|
])?.status,
|
|
GENERAL_CHANNEL_ID,
|
|
),
|
|
)
|
|
.toBe("success");
|
|
await page.evaluate(
|
|
async ({ channelId, pubkey }) => {
|
|
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) throw new Error("Mock bridge is not installed.");
|
|
await invoke("add_channel_members", {
|
|
channelId,
|
|
pubkeys: [pubkey],
|
|
role: "bot",
|
|
});
|
|
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
|
queryKey: ["channels"],
|
|
exact: true,
|
|
});
|
|
},
|
|
{
|
|
channelId: GENERAL_CHANNEL_ID,
|
|
pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY,
|
|
},
|
|
);
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@carl");
|
|
|
|
const carlRow = autocomplete(page).locator("button", { hasText: "carl" });
|
|
await expect(carlRow).toBeVisible();
|
|
await expect(carlRow.getByText("agent")).toBeVisible();
|
|
await expect(carlRow.getByText("not in channel")).toHaveCount(0);
|
|
});
|
|
|
|
test("relay-agent directory errors fail closed and recover after a fresh fetch", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
relayAgentListErrors: ["mock directory unavailable", null],
|
|
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();
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@quinn");
|
|
await expect(autocomplete(page)).toHaveCount(0);
|
|
|
|
await page.evaluate(async () => {
|
|
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
|
queryKey: ["relay-agents"],
|
|
});
|
|
});
|
|
await expect(autocomplete(page).getByText("quinn")).toBeVisible();
|
|
|
|
await page.evaluate(() => {
|
|
window.__BUZZ_E2E__.mock ??= {};
|
|
window.__BUZZ_E2E__.mock.agentListDelayMs = 1_000;
|
|
void window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
|
queryKey: ["relay-agents"],
|
|
});
|
|
});
|
|
await expect
|
|
.poll(async () =>
|
|
page.evaluate(
|
|
() =>
|
|
window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["relay-agents"])
|
|
?.fetchStatus,
|
|
),
|
|
)
|
|
.toBe("fetching");
|
|
await expect(autocomplete(page).getByText("quinn")).toBeVisible({
|
|
timeout: 200,
|
|
});
|
|
await expect
|
|
.poll(async () =>
|
|
page.evaluate(
|
|
() =>
|
|
window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["relay-agents"])
|
|
?.fetchStatus,
|
|
),
|
|
)
|
|
.toBe("idle");
|
|
await expect(autocomplete(page).getByText("quinn")).toBeVisible();
|
|
});
|
|
|
|
test("relay-only allowlisted agents emit a p tag when sent", 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 page.evaluate(
|
|
async ({ channelId, pubkey }) => {
|
|
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) throw new Error("Mock bridge is not installed.");
|
|
await invoke("add_channel_members", {
|
|
channelId,
|
|
pubkeys: [pubkey],
|
|
role: "bot",
|
|
});
|
|
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
|
queryKey: ["channels"],
|
|
});
|
|
},
|
|
{
|
|
channelId: GENERAL_CHANNEL_ID,
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
},
|
|
);
|
|
|
|
const input = page.getByTestId("message-input");
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
await input.fill("@quinn");
|
|
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
|
await expect(quinnRow).toBeVisible();
|
|
await quinnRow.click();
|
|
await page.keyboard.type("hello");
|
|
await expect(input).toHaveText("@quinn hello");
|
|
}
|
|
const baselineCommands = await readCommandLog(page);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
await expect
|
|
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
|
.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
|
|
|
const commands = await readCommandLog(page);
|
|
// Two targeted revalidations: the pre-side-effect admission pass, then the
|
|
// publish-boundary pass, which is unconditional — even this fast path (member
|
|
// agent, no deferred upload/preview, no DM expansion, no huddle) re-runs it.
|
|
expect(commandCount(commands, "revalidate_relay_agents")).toBe(
|
|
commandCount(baselineCommands, "revalidate_relay_agents") + 2,
|
|
);
|
|
expect(commandCount(commands, "list_relay_agents")).toBe(
|
|
commandCount(baselineCommands, "list_relay_agents"),
|
|
);
|
|
});
|
|
|
|
test("managed agents keep their p tag when relay discovery fails before send", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
name: "quinn",
|
|
status: "running",
|
|
},
|
|
],
|
|
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();
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@quinn");
|
|
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
|
await expect(quinnRow).toBeVisible();
|
|
await quinnRow.click();
|
|
await expect(input).toHaveText("@quinn ");
|
|
await page.keyboard.type("hello");
|
|
await expect(input).toHaveText("@quinn hello");
|
|
|
|
await page.evaluate(() => {
|
|
window.__BUZZ_E2E__.mock ??= {};
|
|
window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(5).fill(
|
|
"mock unrelated relay directory failure",
|
|
);
|
|
});
|
|
await page.getByTestId("send-message").click();
|
|
|
|
await expect
|
|
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
|
.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
|
});
|
|
|
|
test("targeted revocation before send causes no agent side effects", 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 page.evaluate(
|
|
async ({ channelId, pubkey }) => {
|
|
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) throw new Error("Mock bridge is not installed.");
|
|
await invoke("add_channel_members", {
|
|
channelId,
|
|
pubkeys: [pubkey],
|
|
role: "bot",
|
|
});
|
|
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
|
queryKey: ["channels"],
|
|
});
|
|
},
|
|
{
|
|
channelId: GENERAL_CHANNEL_ID,
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
},
|
|
);
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@quinn");
|
|
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
|
await expect(quinnRow).toBeVisible();
|
|
await quinnRow.click();
|
|
await page.keyboard.type("hello");
|
|
|
|
await page.evaluate((pubkey) => {
|
|
window.__BUZZ_E2E__.mock ??= {};
|
|
window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey];
|
|
}, ALLOWLIST_RELAY_AGENT_PUBKEY);
|
|
const baselineCommands = await readCommandLog(page);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
await expect(
|
|
page.getByText(/Could not authorize a mentioned agent/),
|
|
).toBeVisible();
|
|
await expect(input).toHaveText("@quinn hello");
|
|
expect(await readOutgoingMentionPubkeys(page, "@quinn hello")).toBeNull();
|
|
const commands = await readCommandLog(page);
|
|
// Admission pass plus the unconditional publish-boundary pass.
|
|
expect(commandCount(commands, "revalidate_relay_agents")).toBe(
|
|
commandCount(baselineCommands, "revalidate_relay_agents") + 1,
|
|
);
|
|
expect(commandCount(commands, "list_relay_agents")).toBe(
|
|
commandCount(baselineCommands, "list_relay_agents"),
|
|
);
|
|
for (const command of [
|
|
"add_channel_members",
|
|
"start_managed_agent",
|
|
"attach_managed_agent",
|
|
"sync_agents_to_active_huddle",
|
|
]) {
|
|
expect(commandCount(commands, command)).toBe(
|
|
commandCount(baselineCommands, command),
|
|
);
|
|
}
|
|
});
|
|
|
|
test("deferred-upload sends revalidate agent authorization at the publish boundary", async ({
|
|
page,
|
|
}) => {
|
|
// A background media upload can hold the publish open for arbitrarily long —
|
|
// authorization revoked during that window must block publication. This
|
|
// pins the publish-boundary revalidation on the deferred path.
|
|
await installMockBridge(page, {
|
|
deferredComposerUploads: true,
|
|
uploadDelayMs: 1_500,
|
|
uploadDescriptors: [
|
|
{
|
|
url: `https://mock.relay/media/${"c".repeat(64)}.mp4`,
|
|
sha256: "c".repeat(64),
|
|
size: 1024 * 1024,
|
|
type: "video/mp4",
|
|
uploaded: Math.floor(Date.now() / 1000),
|
|
filename: "upload-race.mp4",
|
|
},
|
|
],
|
|
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 page.evaluate(
|
|
async ({ channelId, pubkey }) => {
|
|
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) throw new Error("Mock bridge is not installed.");
|
|
await invoke("add_channel_members", {
|
|
channelId,
|
|
pubkeys: [pubkey],
|
|
role: "bot",
|
|
});
|
|
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
|
queryKey: ["channels"],
|
|
});
|
|
},
|
|
{
|
|
channelId: GENERAL_CHANNEL_ID,
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
},
|
|
);
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@quinn");
|
|
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
|
await expect(quinnRow).toBeVisible();
|
|
await quinnRow.click();
|
|
await page.keyboard.type("hello");
|
|
|
|
// Only video files queue until send; anything else uploads at attach time.
|
|
const [chooser] = await Promise.all([
|
|
page.waitForEvent("filechooser"),
|
|
page.getByRole("button", { name: "Attach file" }).click(),
|
|
]);
|
|
await chooser.setFiles({
|
|
buffer: Buffer.alloc(1024 * 1024, 1),
|
|
mimeType: "video/mp4",
|
|
name: "upload-race.mp4",
|
|
});
|
|
await expect(
|
|
page.getByTestId("composer-queued-media-attachment"),
|
|
).toBeVisible();
|
|
|
|
const baselineCommands = await readCommandLog(page);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
// Revoke after the pre-side-effect pass has been admitted but while the
|
|
// deferred upload (1.5s mock delay) still holds the publish open.
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "revalidate_relay_agents"),
|
|
)
|
|
.toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1);
|
|
await page.evaluate(() => {
|
|
window.__BUZZ_E2E__.mock ??= {};
|
|
window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(100).fill(
|
|
"mock directory revoked during deferred upload",
|
|
);
|
|
});
|
|
|
|
const outgoingContent = `@quinn hello\n}.mp4)`;
|
|
await expect(
|
|
page.getByText("Could not authorize a mentioned agent.", { exact: false }),
|
|
).toBeVisible();
|
|
expect(await readOutgoingMentionPubkeys(page, outgoingContent)).toBeNull();
|
|
await expect(input).toHaveText("@quinn hello");
|
|
await expect(
|
|
page.getByTestId("composer-queued-media-attachment"),
|
|
).toBeVisible();
|
|
const commands = await readCommandLog(page);
|
|
expect(commandCount(commands, "revalidate_relay_agents")).toBe(
|
|
commandCount(baselineCommands, "revalidate_relay_agents") + 2,
|
|
);
|
|
expect(commandCount(commands, "start_managed_agent")).toBe(
|
|
commandCount(baselineCommands, "start_managed_agent"),
|
|
);
|
|
});
|
|
|
|
test("sends that attach a mentioned agent revalidate at the publish boundary", async ({
|
|
page,
|
|
}) => {
|
|
// The awaited membership write for a non-member managed agent is a relay
|
|
// round-trip between the pre-side-effect authorization pass and the publish
|
|
// — authorization revoked during that window must block publication.
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY,
|
|
name: "fizz",
|
|
status: "running",
|
|
// Already matching the reusable-agent policy: no update_managed_agent
|
|
// write below, so the attach write alone re-opens the window.
|
|
respondTo: "owner-only",
|
|
respondToAllowlist: [],
|
|
},
|
|
],
|
|
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 page.evaluate(
|
|
async ({ channelId, pubkey }) => {
|
|
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) throw new Error("Mock bridge is not installed.");
|
|
await invoke("add_channel_members", {
|
|
channelId,
|
|
pubkeys: [pubkey],
|
|
role: "bot",
|
|
});
|
|
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
|
queryKey: ["channels"],
|
|
});
|
|
},
|
|
{
|
|
channelId: GENERAL_CHANNEL_ID,
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
},
|
|
);
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@quinn");
|
|
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
|
await expect(quinnRow).toBeVisible();
|
|
await quinnRow.click();
|
|
await page.keyboard.type("@fizz");
|
|
const fizzRow = autocomplete(page).locator("button", { hasText: "fizz" });
|
|
await expect(fizzRow).toBeVisible();
|
|
await expect(fizzRow.getByText("not in channel")).toBeVisible();
|
|
await fizzRow.click();
|
|
await page.keyboard.type("hello");
|
|
await expect(input).toHaveText("@quinn @fizz hello");
|
|
|
|
// Hold the attach's membership write open so the revocation below lands
|
|
// inside the pass-to-publish window.
|
|
await page.evaluate(() => {
|
|
window.__BUZZ_E2E__.mock ??= {};
|
|
window.__BUZZ_E2E__.mock.addChannelMembersDelayMs = 1_500;
|
|
});
|
|
const baselineCommands = await readCommandLog(page);
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
|
|
|
// Revoke quinn after the pre-side-effect pass has been admitted but while
|
|
// the attach still holds the publish open.
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "revalidate_relay_agents"),
|
|
)
|
|
.toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1);
|
|
await page.evaluate((pubkey) => {
|
|
window.__BUZZ_E2E__.mock ??= {};
|
|
window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey];
|
|
}, ALLOWLIST_RELAY_AGENT_PUBKEY);
|
|
|
|
await expect(
|
|
page.getByText("Could not authorize a mentioned agent.", { exact: false }),
|
|
).toBeVisible();
|
|
expect(
|
|
await readOutgoingMentionPubkeys(page, "@quinn @fizz hello"),
|
|
).toBeNull();
|
|
await expect(input).toHaveText("@quinn @fizz hello");
|
|
const commands = await readCommandLog(page);
|
|
expect(commandCount(commands, "revalidate_relay_agents")).toBe(
|
|
commandCount(baselineCommands, "revalidate_relay_agents") + 2,
|
|
);
|
|
// The policy already matched: the attach's membership write was the only
|
|
// relay round-trip holding the publish open for the revocation to land in.
|
|
expect(commandCount(commands, "update_managed_agent")).toBe(
|
|
commandCount(baselineCommands, "update_managed_agent"),
|
|
);
|
|
expect(commandCount(commands, "start_managed_agent")).toBe(
|
|
commandCount(baselineCommands, "start_managed_agent"),
|
|
);
|
|
});
|
|
|
|
test("sends that enroll agents into an active huddle revalidate at the publish boundary", async ({
|
|
page,
|
|
}) => {
|
|
// With a huddle live on the channel, the awaited huddle enrollment is a
|
|
// relay round-trip between the authorization pass and the publish; the
|
|
// publish boundary re-validates rather than trusting the earlier pass.
|
|
await installMockBridge(page, {
|
|
huddle: {
|
|
parentChannelId: GENERAL_CHANNEL_ID,
|
|
ephemeralChannelId: HUDDLE_EPHEMERAL_CHANNEL_ID,
|
|
members: [{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }],
|
|
},
|
|
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 page.evaluate(
|
|
async ({ channelId, pubkey }) => {
|
|
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) throw new Error("Mock bridge is not installed.");
|
|
await invoke("add_channel_members", {
|
|
channelId,
|
|
pubkeys: [pubkey],
|
|
role: "bot",
|
|
});
|
|
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
|
queryKey: ["channels"],
|
|
});
|
|
},
|
|
{
|
|
channelId: GENERAL_CHANNEL_ID,
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
},
|
|
);
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@quinn");
|
|
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
|
await expect(quinnRow).toBeVisible();
|
|
await quinnRow.click();
|
|
await page.keyboard.type("hello");
|
|
|
|
const baselineCommands = await readCommandLog(page);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
await expect
|
|
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
|
.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
|
const commands = await readCommandLog(page);
|
|
expect(commandCount(commands, "sync_agents_to_active_huddle")).toBe(
|
|
commandCount(baselineCommands, "sync_agents_to_active_huddle") + 1,
|
|
);
|
|
expect(commandCount(commands, "revalidate_relay_agents")).toBe(
|
|
commandCount(baselineCommands, "revalidate_relay_agents") + 2,
|
|
);
|
|
});
|
|
|
|
test("a send held open by a no-write step still revalidates at the publish boundary", async ({
|
|
page,
|
|
}) => {
|
|
// The publish boundary revalidates unconditionally, however brief the gap:
|
|
// here the only thing separating the authorization pass from the publish is
|
|
// the huddle sync — which with no active huddle writes nothing to the relay
|
|
// — and the revocation is released with zero further hold. A revocation
|
|
// landing in any admission-to-publish gap must block publication; this is the
|
|
// reviewer's sub-threshold probe of the since-removed elapsed-time bound,
|
|
// which deliberately accepted this very staleness.
|
|
await installMockBridge(page, {
|
|
// Released on demand below; long enough that it is never waited out.
|
|
syncAgentsToActiveHuddleDelayMs: 45_000,
|
|
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();
|
|
// Already a member, so readiness short-circuits: no access-policy read, no
|
|
// membership write, no wake.
|
|
await page.evaluate(
|
|
async ({ channelId, pubkey }) => {
|
|
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) throw new Error("Mock bridge is not installed.");
|
|
await invoke("add_channel_members", {
|
|
channelId,
|
|
pubkeys: [pubkey],
|
|
role: "bot",
|
|
});
|
|
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
|
queryKey: ["channels"],
|
|
});
|
|
},
|
|
{
|
|
channelId: GENERAL_CHANNEL_ID,
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
},
|
|
);
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@quinn");
|
|
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
|
await expect(quinnRow).toBeVisible();
|
|
await quinnRow.click();
|
|
await page.keyboard.type("hello");
|
|
await expect(input).toHaveText("@quinn hello");
|
|
|
|
const baselineCommands = await readCommandLog(page);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
// The pre-side-effect pass has admitted quinn; the huddle sync now holds the
|
|
// publish open. Revoke before releasing, so the ordering is deterministic by
|
|
// construction rather than by timing.
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "revalidate_relay_agents"),
|
|
)
|
|
.toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1);
|
|
await page.evaluate((pubkey) => {
|
|
window.__BUZZ_E2E__.mock ??= {};
|
|
window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey];
|
|
}, ALLOWLIST_RELAY_AGENT_PUBKEY);
|
|
|
|
// Release immediately — no post-revocation hold. Any conditional reuse of
|
|
// the admission pass (a trigger enumeration, an elapsed-time bound) would
|
|
// publish quinn's stale p tag here.
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() => window.__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__?.() ?? 0,
|
|
),
|
|
)
|
|
.toBeGreaterThan(0);
|
|
|
|
await expect(
|
|
page.getByText("Could not authorize a mentioned agent.", { exact: false }),
|
|
).toBeVisible();
|
|
expect(await readOutgoingMentionPubkeys(page, "@quinn hello")).toBeNull();
|
|
await expect(input).toHaveText("@quinn hello");
|
|
|
|
const commands = await readCommandLog(page);
|
|
expect(commandCount(commands, "revalidate_relay_agents")).toBe(
|
|
commandCount(baselineCommands, "revalidate_relay_agents") + 2,
|
|
);
|
|
expect(commandCount(commands, "sync_agents_to_active_huddle")).toBe(
|
|
commandCount(baselineCommands, "sync_agents_to_active_huddle") + 1,
|
|
);
|
|
// Nothing on this leg wrote relay state — the second pass exists only
|
|
// because the publish boundary is unconditional.
|
|
for (const command of [
|
|
"add_channel_members",
|
|
"attach_managed_agent",
|
|
"update_managed_agent",
|
|
"start_managed_agent",
|
|
]) {
|
|
expect(commandCount(commands, command)).toBe(
|
|
commandCount(baselineCommands, command),
|
|
);
|
|
}
|
|
});
|
|
|
|
test("selected relay agents are invited as bots before sending", 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();
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@quinn");
|
|
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
|
await expect(quinnRow).toBeVisible();
|
|
await expect(quinnRow.getByText("not in channel")).toHaveCount(0);
|
|
await quinnRow.click();
|
|
await page.keyboard.type("hello");
|
|
|
|
const baselinePayloadCount = (await readCommandPayloadLog(page)).length;
|
|
await page.getByTestId("send-message").click();
|
|
const inviteButton = page.getByRole("button", {
|
|
name: "Invite",
|
|
exact: true,
|
|
});
|
|
await expect(inviteButton).toBeVisible();
|
|
await inviteButton.click();
|
|
|
|
await expect
|
|
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
|
.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
|
const sendCommands = (await readCommandPayloadLog(page)).slice(
|
|
baselinePayloadCount,
|
|
);
|
|
const addCommand = sendCommands.find(
|
|
(entry) => entry.command === "add_channel_members",
|
|
);
|
|
expect(addCommand?.payload).toMatchObject({
|
|
channelId: GENERAL_CHANNEL_ID,
|
|
pubkeys: [ALLOWLIST_RELAY_AGENT_PUBKEY],
|
|
role: "bot",
|
|
});
|
|
});
|
|
|
|
test("selected relay agents revoked after the invite prompt cause no side effects", 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();
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@quinn");
|
|
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
|
await expect(quinnRow).toBeVisible();
|
|
await quinnRow.click();
|
|
await page.keyboard.type("hello");
|
|
await page.getByTestId("send-message").click();
|
|
const inviteButton = page.getByRole("button", {
|
|
name: "Invite",
|
|
exact: true,
|
|
});
|
|
await expect(inviteButton).toBeVisible();
|
|
|
|
await page.evaluate(() => {
|
|
window.__BUZZ_E2E__.mock ??= {};
|
|
window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(5).fill(
|
|
"mock directory revoked after invite prompt",
|
|
);
|
|
});
|
|
const baselineCommands = await readCommandLog(page);
|
|
await inviteButton.click();
|
|
|
|
await expect(
|
|
page.getByText(/Could not authorize a mentioned agent/),
|
|
).toBeVisible();
|
|
await expect(input).toHaveText("@quinn hello");
|
|
expect(await readOutgoingMentionPubkeys(page, "@quinn hello")).toBeNull();
|
|
const commands = await readCommandLog(page);
|
|
for (const command of [
|
|
"add_channel_members",
|
|
"start_managed_agent",
|
|
"attach_managed_agent",
|
|
"sync_agents_to_active_huddle",
|
|
]) {
|
|
expect(commandCount(commands, command)).toBe(
|
|
commandCount(baselineCommands, command),
|
|
);
|
|
}
|
|
});
|
|
|
|
test("selected relay agents revoked during send emit no p tag", 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();
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@quinn");
|
|
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
|
await expect(quinnRow).toBeVisible();
|
|
await quinnRow.click();
|
|
await page.keyboard.type("hello");
|
|
|
|
await page.evaluate(() => {
|
|
window.__BUZZ_E2E__.mock ??= {};
|
|
window.__BUZZ_E2E__.mock.agentListDelayMs = 300;
|
|
});
|
|
await page.getByTestId("send-message").click();
|
|
await page.getByRole("button", { name: "Invite", exact: true }).click();
|
|
await page.evaluate(() => {
|
|
window.__BUZZ_E2E__.mock ??= {};
|
|
window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(100).fill(
|
|
"mock directory revoked mid-send",
|
|
);
|
|
});
|
|
|
|
await expect(
|
|
page.getByText(/Could not authorize a mentioned agent/),
|
|
).toBeVisible();
|
|
await expect(input).toHaveText("@quinn hello");
|
|
expect(await readOutgoingMentionPubkeys(page, "@quinn hello")).toBeNull();
|
|
});
|
|
|
|
test("owner-only builds admit cross-owner relay agents authorized by allowlist", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
ownerOnlyAccessBuild: true,
|
|
searchProfiles: [
|
|
{
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
displayName: "quinn",
|
|
ownerPubkey: TEST_IDENTITIES.outsider.pubkey,
|
|
isAgent: true,
|
|
},
|
|
],
|
|
relayAgents: [
|
|
{
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
ownerPubkey: TEST_IDENTITIES.outsider.pubkey,
|
|
name: "quinn",
|
|
respondTo: "allowlist",
|
|
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
|
|
channelNames: ["general"],
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.evaluate(
|
|
async ({ channelId, pubkey }) => {
|
|
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) throw new Error("Mock bridge is not installed.");
|
|
await invoke("add_channel_members", {
|
|
channelId,
|
|
pubkeys: [pubkey],
|
|
role: "bot",
|
|
});
|
|
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
|
queryKey: ["channels"],
|
|
});
|
|
},
|
|
{
|
|
channelId: GENERAL_CHANNEL_ID,
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
},
|
|
);
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@quinn");
|
|
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
|
await expect(quinnRow).toBeVisible();
|
|
await quinnRow.click();
|
|
await page.keyboard.type("hello");
|
|
await page.getByTestId("send-message").click();
|
|
|
|
await expect
|
|
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
|
.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
|
});
|
|
|
|
test("owner-only builds show verified same-owner relay agents", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
ownerOnlyAccessBuild: true,
|
|
searchProfiles: [
|
|
{
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
displayName: "quinn",
|
|
ownerPubkey: MOCK_VIEWER_PUBKEY,
|
|
isAgent: true,
|
|
},
|
|
],
|
|
relayAgents: [
|
|
{
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
ownerPubkey: MOCK_VIEWER_PUBKEY,
|
|
name: "quinn",
|
|
respondTo: "owner-only",
|
|
channelNames: ["general"],
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.getByTestId("message-input").fill("@quinn");
|
|
|
|
await expect(autocomplete(page).getByText("quinn")).toBeVisible();
|
|
});
|
|
|
|
test("relay-only allowlisted agents stay hidden outside their channel", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
relayAgents: [
|
|
{
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
name: "quinn",
|
|
respondTo: "allowlist",
|
|
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
|
|
channelNames: ["agents"],
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await page.getByTestId("message-input").fill("@quinn");
|
|
|
|
await expect(autocomplete(page)).toHaveCount(0);
|
|
});
|
|
|
|
test("owner-only builds admit cross-owner relay agents authorized for anyone", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
ownerOnlyAccessBuild: true,
|
|
searchProfiles: [
|
|
{
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
displayName: "quinn",
|
|
ownerPubkey: TEST_IDENTITIES.outsider.pubkey,
|
|
isAgent: true,
|
|
},
|
|
],
|
|
relayAgents: [
|
|
{
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
ownerPubkey: TEST_IDENTITIES.outsider.pubkey,
|
|
name: "quinn",
|
|
respondTo: "anyone",
|
|
channelNames: ["general"],
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await page.getByTestId("message-input").fill("@quinn");
|
|
|
|
await expect(autocomplete(page).getByText("quinn")).toBeVisible();
|
|
});
|
|
|
|
test("relay-only excluded agents stay hidden from channel mentions", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
relayAgents: [
|
|
{
|
|
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
|
name: "quinn",
|
|
respondTo: "allowlist",
|
|
respondToAllowlist: [TEST_IDENTITIES.outsider.pubkey],
|
|
channelNames: ["general"],
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await page.getByTestId("message-input").fill("@quinn");
|
|
|
|
await expect(autocomplete(page)).toHaveCount(0);
|
|
});
|
|
|
|
test("shared agents wait for initial directory authorization", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
agentListDelayMs: 1_000,
|
|
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");
|
|
|
|
await page.getByTestId("message-input").fill("@quinn");
|
|
|
|
await expect(autocomplete(page)).toHaveCount(0);
|
|
await expect(autocomplete(page).getByText("quinn")).toBeVisible({
|
|
timeout: 3_000,
|
|
});
|
|
});
|
|
|
|
test("mentioning an in-channel stopped managed agent publishes first and starts it detached", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY,
|
|
name: "fizz",
|
|
status: "stopped",
|
|
channelNames: ["general"],
|
|
},
|
|
],
|
|
// Far longer than the test runs: the publish landing below proves the
|
|
// send no longer waits for start_managed_agent to resolve.
|
|
startManagedAgentDelayMs: 45_000,
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Hey @fizz");
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown.getByText("fizz")).toBeVisible();
|
|
await expect(dropdown.getByText("agent")).toBeVisible();
|
|
await input.press("Enter");
|
|
await page.keyboard.type(" can you help?");
|
|
|
|
const baselineCommands = await readCommandLog(page);
|
|
const baselineStartCount = commandCount(
|
|
baselineCommands,
|
|
"start_managed_agent",
|
|
);
|
|
const baselineSignCount = commandCount(baselineCommands, "sign_event");
|
|
const baselinePayloadCount = (await readCommandPayloadLog(page)).length;
|
|
await page.getByTestId("send-message").click();
|
|
|
|
// Publish-first: the message signs and renders while start_managed_agent
|
|
// is still pending behind the injected delay.
|
|
await expect
|
|
.poll(async () => commandCount(await readCommandLog(page), "sign_event"))
|
|
.toBeGreaterThan(baselineSignCount);
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "start_managed_agent"),
|
|
)
|
|
.toBeGreaterThan(baselineStartCount);
|
|
|
|
// The detached start carries a replay floor so the spawned harness's first
|
|
// REQ replays past the just-published message.
|
|
const startCall = (await readCommandPayloadLog(page))
|
|
.slice(baselinePayloadCount)
|
|
.find((entry) => entry.command === "start_managed_agent");
|
|
expect(
|
|
(startCall?.payload as { replayFloorUnix?: number } | undefined)
|
|
?.replayFloorUnix,
|
|
).toBeGreaterThan(0);
|
|
// It also carries the tenant scope active at the send. The start now
|
|
// outlives the send, and a community switch only remounts the React
|
|
// subtree, so an unscoped wake would spawn against whichever relay/identity
|
|
// is current when it lands; the backend fails closed on these instead.
|
|
const activeRelayUrl = await page.evaluate(() => {
|
|
const communities = JSON.parse(
|
|
window.localStorage.getItem("buzz-communities") ?? "[]",
|
|
) as { id: string; relayUrl: string }[];
|
|
const activeId = window.localStorage.getItem("buzz-active-community-id");
|
|
return (
|
|
communities.find((community) => community.id === activeId)?.relayUrl ?? ""
|
|
);
|
|
});
|
|
expect(activeRelayUrl).not.toBe("");
|
|
expect(startCall?.payload).toMatchObject({
|
|
expectedRelayUrl: activeRelayUrl,
|
|
expectedSignerPubkey: MOCK_VIEWER_PUBKEY,
|
|
});
|
|
// The wake is queued during send preparation and flushed only after the
|
|
// relay accepts the publish, so the sign always precedes the start — a
|
|
// wake can never exist (nor its failure toast "your message was sent"
|
|
// appear) for a message whose publish outcome is still unknown.
|
|
const commandsAfterSend = (await readCommandLog(page)).slice(
|
|
baselineCommands.length,
|
|
);
|
|
expect(commandsAfterSend.indexOf("sign_event")).toBeLessThan(
|
|
commandsAfterSend.indexOf("start_managed_agent"),
|
|
);
|
|
|
|
const mentionChip = page
|
|
.getByTestId("message-row")
|
|
.last()
|
|
.locator("[data-mention].agent-mention-highlight", { hasText: "fizz" });
|
|
await expect(mentionChip).toBeVisible();
|
|
});
|
|
|
|
test("a second mention while the first wake is in flight does not start the agent twice", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY,
|
|
name: "fizz",
|
|
status: "stopped",
|
|
channelNames: ["general"],
|
|
},
|
|
],
|
|
// Held open for the whole test. Awaiting the start used to make a
|
|
// duplicate unreachable — the composer refused to send while one was
|
|
// pending, and by the time it lifted the success handler had cached a
|
|
// running record. Detached, the record keeps reading "stopped" for the
|
|
// whole spawn, which is precisely when a second send re-fires.
|
|
startManagedAgentDelayMs: 45_000,
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
const dropdown = autocomplete(page);
|
|
const baselineCommands = await readCommandLog(page);
|
|
const baselineStartCount = commandCount(
|
|
baselineCommands,
|
|
"start_managed_agent",
|
|
);
|
|
|
|
await input.fill("Hey @fizz");
|
|
await expect(dropdown.getByText("fizz")).toBeVisible();
|
|
await input.press("Enter");
|
|
await expect(input.locator(".mention-chip")).toHaveText("fizz");
|
|
await page.keyboard.type("do X");
|
|
await expect(input).toHaveText("Hey @fizz do X");
|
|
await page.getByTestId("send-message").click();
|
|
await expect(
|
|
page.getByTestId("message-row").filter({ hasText: "do X" }),
|
|
).toBeVisible();
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "start_managed_agent"),
|
|
)
|
|
.toBe(baselineStartCount + 1);
|
|
|
|
await input.fill("Hey @fizz");
|
|
await expect(dropdown.getByText("fizz")).toBeVisible();
|
|
await input.press("Enter");
|
|
await expect(input.locator(".mention-chip")).toHaveText("fizz");
|
|
await page.keyboard.type("also Y");
|
|
await expect(input).toHaveText("Hey @fizz also Y");
|
|
await page.getByTestId("send-message").click();
|
|
|
|
// The second message publishes on its own — suppression is of the wake, not
|
|
// of the send; the composer is never gated on a pending start again.
|
|
await expect(
|
|
page.getByTestId("message-row").filter({ hasText: "also Y" }),
|
|
).toBeVisible();
|
|
expect(await readOutgoingMentionPubkeys(page, "Hey @fizz do X")).toContain(
|
|
IN_CHANNEL_MANAGED_AGENT_PUBKEY,
|
|
);
|
|
expect(await readOutgoingMentionPubkeys(page, "Hey @fizz also Y")).toContain(
|
|
IN_CHANNEL_MANAGED_AGENT_PUBKEY,
|
|
);
|
|
// One wake serves both messages: its replay floor predates the first
|
|
// message, and the floor is a lower bound, so one harness boot covers both.
|
|
expect(commandCount(await readCommandLog(page), "start_managed_agent")).toBe(
|
|
baselineStartCount + 1,
|
|
);
|
|
});
|
|
|
|
test("a detached agent start failure surfaces as a toast after the message sends", async ({
|
|
page,
|
|
}) => {
|
|
const startError = "Mock agent startup failed.";
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY,
|
|
name: "fizz",
|
|
status: "stopped",
|
|
channelNames: ["general"],
|
|
},
|
|
],
|
|
startManagedAgentErrors: [startError],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Hey @fizz");
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown.getByText("fizz")).toBeVisible();
|
|
await input.press("Enter");
|
|
await page.keyboard.type(" can you help?");
|
|
|
|
await page.getByTestId("send-message").click();
|
|
|
|
// The message still publishes — the start runs off the critical path.
|
|
const mentionChip = page
|
|
.getByTestId("message-row")
|
|
.last()
|
|
.locator("[data-mention].agent-mention-highlight", { hasText: "fizz" });
|
|
await expect(mentionChip).toBeVisible();
|
|
|
|
// The failed start surfaces as a post-send toast instead of blocking the
|
|
// send, and the sent text is not restored into the composer. (The
|
|
// persistent agent audience may legitimately re-seed an "@fizz"
|
|
// auto-mention, so only the message body proves there was no
|
|
// failed-send restore.)
|
|
await expect(page.getByText(startError, { exact: false })).toBeVisible();
|
|
await expect(input).not.toContainText("can you help");
|
|
});
|
|
|
|
test("a failed publish drops the queued agent wake and never claims the message was sent", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY,
|
|
name: "fizz",
|
|
status: "stopped",
|
|
channelNames: ["general"],
|
|
},
|
|
],
|
|
// Reject the publish itself. The wake is queued behind it, so a publish
|
|
// that never lands must fire no wake at all — before this ordering, the
|
|
// wake fired during send preparation, rejected fast (the injected start
|
|
// error below), and toasted "your message was sent" while the publish
|
|
// went on to fail with no corrective message.
|
|
sendMessageErrors: ["Mock relay rejected the event."],
|
|
// Armed so that IF a wake still fired it would reject immediately and
|
|
// raise the false-success toast whose absence this spec pins.
|
|
startManagedAgentErrors: ["Mock agent startup failed."],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Hey @fizz");
|
|
await expect(autocomplete(page).getByText("fizz")).toBeVisible();
|
|
await input.press("Enter");
|
|
await page.keyboard.type(" do X");
|
|
|
|
const baselineStartCount = commandCount(
|
|
await readCommandLog(page),
|
|
"start_managed_agent",
|
|
);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
// Deterministic completion signal: the failed send restores the draft into
|
|
// the composer. On the pre-fix ordering the wake had already fired — and
|
|
// toasted — before this point, so the assertions below need no timing games.
|
|
await expect(input).toContainText("do X");
|
|
|
|
// The message never landed: the optimistic row was rolled back...
|
|
await expect(
|
|
page.getByTestId("message-row").filter({ hasText: "do X" }),
|
|
).toHaveCount(0);
|
|
// ...so the queued wake was dropped rather than flushed...
|
|
expect(commandCount(await readCommandLog(page), "start_managed_agent")).toBe(
|
|
baselineStartCount,
|
|
);
|
|
// ...and nothing on screen claims the message was sent.
|
|
await expect(
|
|
page.getByText("your message was sent", { exact: false }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("a detached start fired before a real community switch fails closed and keeps its warning out of the new community", async ({
|
|
page,
|
|
}) => {
|
|
// Two seeded communities and a real rail-button switch: the click drives
|
|
// the actual provider → remount → resetCommunityState path (which clears
|
|
// and repoints the toast-scope mirror), and persists the active community
|
|
// id the mock's scope check reads — so the held start is refused exactly
|
|
// as the real backend would refuse it. The predecessor of this spec moved
|
|
// localStorage directly, which exercised the fail-closed refusal but never
|
|
// the switch itself, and pinned the stale toast's *presence* — the outcome
|
|
// the delivery fence now forbids.
|
|
const COMMUNITY_A = {
|
|
id: "ws-a",
|
|
name: "Alpha",
|
|
relayUrl: "ws://localhost:3000",
|
|
addedAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
const COMMUNITY_B = {
|
|
id: "ws-b",
|
|
name: "Bravo",
|
|
relayUrl: "ws://localhost:3001",
|
|
addedAt: "2026-01-02T00:00:00.000Z",
|
|
};
|
|
await installMockBridge(
|
|
page,
|
|
{
|
|
managedAgents: [
|
|
{
|
|
pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY,
|
|
name: "fizz",
|
|
status: "stopped",
|
|
channelNames: ["general"],
|
|
},
|
|
],
|
|
// Holds the start open long enough for the real switch below to
|
|
// complete under it — the window the detached (publish-first) wake
|
|
// opened.
|
|
startManagedAgentDelayMs: 3_000,
|
|
},
|
|
{ skipCommunitySeed: true },
|
|
);
|
|
await page.addInitScript(
|
|
({ list, active }) => {
|
|
window.localStorage.setItem("buzz-communities", JSON.stringify(list));
|
|
window.localStorage.setItem("buzz-active-community-id", active);
|
|
},
|
|
{ list: [COMMUNITY_A, COMMUNITY_B], active: COMMUNITY_A.id },
|
|
);
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Hey @fizz");
|
|
await expect(autocomplete(page).getByText("fizz")).toBeVisible();
|
|
await input.press("Enter");
|
|
await page.keyboard.type(" can you help?");
|
|
|
|
const baselineCommands = await readCommandLog(page);
|
|
const baselineStartCount = commandCount(
|
|
baselineCommands,
|
|
"start_managed_agent",
|
|
);
|
|
const baselineSettledCount = commandCount(
|
|
baselineCommands,
|
|
"start_managed_agent:settled",
|
|
);
|
|
const baselinePayloadCount = (await readCommandPayloadLog(page)).length;
|
|
await page.getByTestId("send-message").click();
|
|
|
|
// The message published in A — only the wake is at stake from here on.
|
|
const mentionChip = page
|
|
.getByTestId("message-row")
|
|
.last()
|
|
.locator("[data-mention].agent-mention-highlight", { hasText: "fizz" });
|
|
await expect(mentionChip).toBeVisible();
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "start_managed_agent"),
|
|
)
|
|
.toBeGreaterThan(baselineStartCount);
|
|
// The wake carries A's scope, so the backend fails it closed once B is
|
|
// active — the unit tests can't pin the real invoke payload.
|
|
const startCall = (await readCommandPayloadLog(page))
|
|
.slice(baselinePayloadCount)
|
|
.find((entry) => entry.command === "start_managed_agent");
|
|
expect(startCall?.payload).toMatchObject({
|
|
expectedRelayUrl: COMMUNITY_A.relayUrl,
|
|
expectedSignerPubkey: MOCK_VIEWER_PUBKEY,
|
|
});
|
|
|
|
// The real switch, while the start is still held.
|
|
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
|
await expect(
|
|
page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`),
|
|
).toHaveAttribute("aria-current", "true");
|
|
|
|
// Wait for the held start to actually settle (the scope refusal fires
|
|
// after the injected delay), then give the rejection a beat to reach the
|
|
// hook's catch. Only past this point is the negative assertion below
|
|
// falsifiable: pre-fence, the stale toast appeared at settlement and stayed
|
|
// on screen for seconds.
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
commandCount(await readCommandLog(page), "start_managed_agent:settled"),
|
|
{ timeout: 10_000 },
|
|
)
|
|
.toBeGreaterThan(baselineSettledCount);
|
|
await page.waitForTimeout(500);
|
|
|
|
// B is on screen and community A's failure never toasts over it. The
|
|
// suppression is logged to the console instead; an A→B→A round-trip would
|
|
// re-arm delivery (pinned at the unit level). These counts are immediate
|
|
// snapshots, not retrying toHaveCount(0) assertions — a retry would simply
|
|
// wait out the toast's auto-dismiss and pass against the very toast it
|
|
// forbids.
|
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
|
expect(
|
|
await page.getByText("Could not start fizz", { exact: false }).count(),
|
|
).toBe(0);
|
|
expect(
|
|
await page.getByText("your message was sent", { exact: false }).count(),
|
|
).toBe(0);
|
|
});
|
|
|
|
test("a deploy held across an A→B→A community round-trip is not fired twice", async ({
|
|
page,
|
|
}) => {
|
|
// The in-flight detached-start map used to be cleared by every community
|
|
// switch, and the backend's scope assertion is a current-state check — so a
|
|
// deploy still held from community A became valid again the moment A was
|
|
// re-applied, and a second mention back in A deployed the agent a second
|
|
// time (carrying the second message's replay floor, past the first
|
|
// message). The entries are tenant-keyed and self-cleaning, so they now
|
|
// survive the switch. This drives the real rail-switch path (provider →
|
|
// remount → resetCommunityState) that did the clearing; the map contract
|
|
// itself is pinned at the unit level.
|
|
const COMMUNITY_A = {
|
|
id: "ws-a",
|
|
name: "Alpha",
|
|
relayUrl: "ws://localhost:3000",
|
|
addedAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
const COMMUNITY_B = {
|
|
id: "ws-b",
|
|
name: "Bravo",
|
|
relayUrl: "ws://localhost:3001",
|
|
addedAt: "2026-01-02T00:00:00.000Z",
|
|
};
|
|
await installMockBridge(
|
|
page,
|
|
{
|
|
managedAgents: [
|
|
{
|
|
pubkey: OUT_OF_CHANNEL_PROVIDER_AGENT_PUBKEY,
|
|
name: "portal",
|
|
status: "not_deployed",
|
|
channelNames: ["general"],
|
|
backend: {
|
|
type: "provider",
|
|
id: "portal",
|
|
config: { region: "test" },
|
|
},
|
|
},
|
|
],
|
|
// Far longer than the round-trip below ever takes, so the first deploy
|
|
// is deterministically still in flight when the second send fires;
|
|
// settled on demand via the release seam for the retry leg.
|
|
startManagedAgentDelayMs: 45_000,
|
|
// Arms the first settlement to reject. A successful mock settle writes
|
|
// `deployed` into the record, and the third send below would then skip
|
|
// the wake on status alone — the retry leg has to prove the *map entry*
|
|
// self-cleaned, so the record must still read `not_deployed`.
|
|
startManagedAgentErrors: ["Mock provider deploy failed."],
|
|
},
|
|
{ skipCommunitySeed: true },
|
|
);
|
|
await page.addInitScript(
|
|
({ list, active }) => {
|
|
window.localStorage.setItem("buzz-communities", JSON.stringify(list));
|
|
window.localStorage.setItem("buzz-active-community-id", active);
|
|
},
|
|
{ list: [COMMUNITY_A, COMMUNITY_B], active: COMMUNITY_A.id },
|
|
);
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const sendMention = async (text: string) => {
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Hey @portal");
|
|
await expect(autocomplete(page).getByText("portal")).toBeVisible();
|
|
await input.press("Enter");
|
|
await page.keyboard.type(` ${text}`);
|
|
// Enter rather than the send button: the third send happens while the
|
|
// first deploy's failure toast is on screen, and the toast overlay
|
|
// intercepts pointer events aimed at the composer's corner.
|
|
await input.press("Enter");
|
|
await expect(
|
|
page.getByTestId("message-row").filter({ hasText: text }),
|
|
).toBeVisible();
|
|
};
|
|
|
|
const baselineCommands = await readCommandLog(page);
|
|
const baselineStartCount = commandCount(
|
|
baselineCommands,
|
|
"start_managed_agent",
|
|
);
|
|
const baselineSettledCount = commandCount(
|
|
baselineCommands,
|
|
"start_managed_agent:settled",
|
|
);
|
|
const baselinePayloadCount = (await readCommandPayloadLog(page)).length;
|
|
|
|
await sendMention("do X");
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "start_managed_agent"),
|
|
)
|
|
.toBe(baselineStartCount + 1);
|
|
const startCall = (await readCommandPayloadLog(page))
|
|
.slice(baselinePayloadCount)
|
|
.find((entry) => entry.command === "start_managed_agent");
|
|
expect(startCall?.payload).toMatchObject({
|
|
expectedRelayUrl: COMMUNITY_A.relayUrl,
|
|
expectedSignerPubkey: MOCK_VIEWER_PUBKEY,
|
|
});
|
|
|
|
// The round trip, while the deploy is still held.
|
|
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
|
await expect(
|
|
page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`),
|
|
).toHaveAttribute("aria-current", "true");
|
|
await page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`).click();
|
|
await expect(
|
|
page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`),
|
|
).toHaveAttribute("aria-current", "true");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Back in A, the held deploy's scope is valid again and the record still
|
|
// reads `not_deployed`, so this send queues a wake — the retained map entry
|
|
// is the only thing standing between it and a duplicate deploy. A
|
|
// suppressed wake makes no call, so give the post-publish flush a moment
|
|
// before snapshotting: pre-fix the duplicate invoke landed well inside it.
|
|
await sendMention("also Y");
|
|
await page.waitForTimeout(500);
|
|
expect(commandCount(await readCommandLog(page), "start_managed_agent")).toBe(
|
|
baselineStartCount + 1,
|
|
);
|
|
|
|
// Settle the held deploy on demand (the armed rejection). Retention must
|
|
// end at settlement rather than latching the agent for the session.
|
|
const released = await page.evaluate(
|
|
() =>
|
|
(
|
|
window as {
|
|
__BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__?: () => number;
|
|
}
|
|
).__BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__?.() ?? 0,
|
|
);
|
|
expect(released).toBe(1);
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "start_managed_agent:settled"),
|
|
)
|
|
.toBe(baselineSettledCount + 1);
|
|
// The failure settled with A on screen, so its warning delivers here — the
|
|
// round-trip kept it out of B without dropping it.
|
|
await expect(
|
|
page.getByText("Could not start portal", { exact: false }),
|
|
).toBeVisible();
|
|
|
|
// The map entry self-cleaned at settlement, so a third mention of the
|
|
// still-undeployed agent re-fires the wake.
|
|
await sendMention("try again");
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "start_managed_agent"),
|
|
)
|
|
.toBe(baselineStartCount + 2);
|
|
});
|
|
|
|
test("mentioning an in-channel provider managed agent publishes first and deploys it detached", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: OUT_OF_CHANNEL_PROVIDER_AGENT_PUBKEY,
|
|
name: "portal",
|
|
status: "not_deployed",
|
|
channelNames: ["general"],
|
|
backend: {
|
|
type: "provider",
|
|
id: "portal",
|
|
config: { region: "test" },
|
|
},
|
|
},
|
|
],
|
|
// Far longer than the test runs: the publish landing below proves the
|
|
// send no longer waits for the deploy to resolve.
|
|
startManagedAgentDelayMs: 45_000,
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Hey @portal");
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown.getByText("portal")).toBeVisible();
|
|
await expect(dropdown.getByText("agent")).toBeVisible();
|
|
await input.press("Enter");
|
|
await page.keyboard.type(" can you help?");
|
|
|
|
const baselineCommands = await readCommandLog(page);
|
|
const baselineStartCount = commandCount(
|
|
baselineCommands,
|
|
"start_managed_agent",
|
|
);
|
|
const baselineSignCount = commandCount(baselineCommands, "sign_event");
|
|
const baselinePayloadCount = (await readCommandPayloadLog(page)).length;
|
|
await page.getByTestId("send-message").click();
|
|
|
|
// Publish-first: the message signs and renders while the deploy is still
|
|
// pending behind the injected delay.
|
|
await expect
|
|
.poll(async () => commandCount(await readCommandLog(page), "sign_event"))
|
|
.toBeGreaterThan(baselineSignCount);
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "start_managed_agent"),
|
|
)
|
|
.toBeGreaterThan(baselineStartCount);
|
|
|
|
// The detached deploy carries the replay floor too — the backend threads it
|
|
// into the provider payload's launch.policy_env so the remote harness
|
|
// replays past the just-published message like a local spawn.
|
|
const startCall = (await readCommandPayloadLog(page))
|
|
.slice(baselinePayloadCount)
|
|
.find((entry) => entry.command === "start_managed_agent");
|
|
expect(
|
|
(startCall?.payload as { replayFloorUnix?: number } | undefined)
|
|
?.replayFloorUnix,
|
|
).toBeGreaterThan(0);
|
|
|
|
const mentionChip = page
|
|
.getByTestId("message-row")
|
|
.last()
|
|
.locator("[data-mention].agent-mention-highlight", { hasText: "portal" });
|
|
await expect(mentionChip).toBeVisible();
|
|
});
|
|
|
|
test("mentioning a non-member managed agent adds it before sending and starts it detached", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
personas: [
|
|
{
|
|
id: "persona-owner",
|
|
displayName: "Fizz",
|
|
systemPrompt: "",
|
|
},
|
|
],
|
|
managedAgents: [
|
|
{
|
|
pubkey: OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY,
|
|
name: "fizz",
|
|
personaId: "persona-owner",
|
|
status: "stopped",
|
|
respondTo: "anyone",
|
|
respondToAllowlist: [TEST_IDENTITIES.outsider.pubkey],
|
|
},
|
|
],
|
|
// Far longer than the test runs: the publish landing below proves the
|
|
// send no longer waits for start_managed_agent to resolve.
|
|
startManagedAgentDelayMs: 45_000,
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Loop in @fizz");
|
|
|
|
const dropdown = autocomplete(page);
|
|
const fizzRow = dropdown.locator("button", { hasText: "fizz" });
|
|
await expect(fizzRow).toBeVisible();
|
|
await expect(fizzRow.getByText("not in channel")).toBeVisible();
|
|
await input.press("Enter");
|
|
|
|
const baselineCommands = await readCommandLog(page);
|
|
const baselineAddCount = commandCount(
|
|
baselineCommands,
|
|
"add_channel_members",
|
|
);
|
|
const baselineStartCount = commandCount(
|
|
baselineCommands,
|
|
"start_managed_agent",
|
|
);
|
|
const baselinePayloadCount = (await readCommandPayloadLog(page)).length;
|
|
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
|
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "add_channel_members"),
|
|
)
|
|
.toBeGreaterThan(baselineAddCount);
|
|
// Publish-first: the message signs while start_managed_agent is still
|
|
// pending behind the injected delay.
|
|
await expect
|
|
.poll(async () => commandCount(await readCommandLog(page), "sign_event"))
|
|
.toBeGreaterThan(commandCount(baselineCommands, "sign_event"));
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "start_managed_agent"),
|
|
)
|
|
.toBeGreaterThan(baselineStartCount);
|
|
|
|
const sendCommands = (await readCommandPayloadLog(page)).slice(
|
|
baselinePayloadCount,
|
|
);
|
|
const updateIndex = sendCommands.findIndex(
|
|
(entry) => entry.command === "update_managed_agent",
|
|
);
|
|
const addIndex = sendCommands.findIndex(
|
|
(entry) => entry.command === "add_channel_members",
|
|
);
|
|
const startIndex = sendCommands.findIndex(
|
|
(entry) => entry.command === "start_managed_agent",
|
|
);
|
|
const sendIndex = sendCommands.findIndex(
|
|
(entry) => entry.command === "sign_event",
|
|
);
|
|
expect(updateIndex).toBeGreaterThanOrEqual(0);
|
|
expect(updateIndex).toBeLessThan(addIndex);
|
|
expect(updateIndex).toBeLessThan(startIndex);
|
|
// The access-policy write and the membership write stay ahead of the
|
|
// publish; only the start itself is detached from the send.
|
|
expect(sendIndex).toBeGreaterThanOrEqual(0);
|
|
expect(addIndex).toBeLessThan(sendIndex);
|
|
expect(sendCommands[updateIndex]?.payload).toMatchObject({
|
|
input: {
|
|
pubkey: OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY,
|
|
respondTo: "owner-only",
|
|
respondToAllowlist: [],
|
|
},
|
|
});
|
|
// The detached start carries a replay floor so the spawned harness's first
|
|
// REQ replays past the just-published message.
|
|
expect(
|
|
(
|
|
sendCommands[startIndex]?.payload as
|
|
| { replayFloorUnix?: number }
|
|
| undefined
|
|
)?.replayFloorUnix,
|
|
).toBeGreaterThan(0);
|
|
|
|
const mentionChip = page
|
|
.getByTestId("message-row")
|
|
.last()
|
|
.locator("[data-mention].agent-mention-highlight", { hasText: "fizz" });
|
|
await expect(mentionChip).toBeVisible();
|
|
|
|
const persistedPolicy = await page.evaluate(async (pubkey) => {
|
|
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) throw new Error("Mock bridge is not installed.");
|
|
const agents = (await invoke("list_managed_agents", {})) as Array<{
|
|
pubkey: string;
|
|
respond_to: string;
|
|
respond_to_allowlist: string[];
|
|
}>;
|
|
const agent = agents.find((candidate) => candidate.pubkey === pubkey);
|
|
return agent
|
|
? {
|
|
respondTo: agent.respond_to,
|
|
respondToAllowlist: agent.respond_to_allowlist,
|
|
}
|
|
: null;
|
|
}, OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY);
|
|
expect(persistedPolicy).toEqual({
|
|
respondTo: "owner-only",
|
|
respondToAllowlist: [],
|
|
});
|
|
});
|
|
|
|
test("mentioning a non-member provider managed agent adds it before sending and deploys it detached", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: OUT_OF_CHANNEL_PROVIDER_AGENT_PUBKEY,
|
|
name: "portal",
|
|
status: "not_deployed",
|
|
backend: {
|
|
type: "provider",
|
|
id: "portal",
|
|
config: { region: "test" },
|
|
},
|
|
},
|
|
],
|
|
// Far longer than the test runs: the publish landing below proves the
|
|
// send no longer waits for the deploy to resolve.
|
|
startManagedAgentDelayMs: 45_000,
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Loop in @portal");
|
|
|
|
const dropdown = autocomplete(page);
|
|
const portalRow = dropdown.locator("button", { hasText: "portal" });
|
|
await expect(portalRow).toBeVisible();
|
|
await expect(portalRow.getByText("not in channel")).toBeVisible();
|
|
await input.press("Enter");
|
|
|
|
const baselineCommands = await readCommandLog(page);
|
|
const baselineAddCount = commandCount(
|
|
baselineCommands,
|
|
"add_channel_members",
|
|
);
|
|
const baselineStartCount = commandCount(
|
|
baselineCommands,
|
|
"start_managed_agent",
|
|
);
|
|
const baselineSignCount = commandCount(baselineCommands, "sign_event");
|
|
|
|
await page.getByTestId("send-message").click();
|
|
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
|
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "add_channel_members"),
|
|
)
|
|
.toBeGreaterThan(baselineAddCount);
|
|
// Publish-first: the message signs and renders while the deploy is still
|
|
// pending behind the injected delay.
|
|
await expect
|
|
.poll(async () => commandCount(await readCommandLog(page), "sign_event"))
|
|
.toBeGreaterThan(baselineSignCount);
|
|
await expect
|
|
.poll(async () =>
|
|
commandCount(await readCommandLog(page), "start_managed_agent"),
|
|
)
|
|
.toBeGreaterThan(baselineStartCount);
|
|
|
|
const mentionChip = page
|
|
.getByTestId("message-row")
|
|
.last()
|
|
.locator("[data-mention].agent-mention-highlight", { hasText: "portal" });
|
|
await expect(mentionChip).toBeVisible();
|
|
});
|
|
|
|
test("system add rows use plain names while remove rows retain agent mention styling", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: OUT_OF_CHANNEL_PROVIDER_AGENT_PUBKEY,
|
|
name: "portal",
|
|
status: "deployed",
|
|
backend: {
|
|
type: "provider",
|
|
id: "portal",
|
|
config: { region: "test" },
|
|
},
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND);
|
|
|
|
await page.evaluate(
|
|
({ actorPubkey, kind, targetPubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: JSON.stringify({
|
|
type: "member_joined",
|
|
actor: actorPubkey,
|
|
target: targetPubkey,
|
|
}),
|
|
kind,
|
|
});
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: JSON.stringify({
|
|
type: "member_removed",
|
|
actor: actorPubkey,
|
|
target: targetPubkey,
|
|
}),
|
|
kind,
|
|
});
|
|
},
|
|
{
|
|
actorPubkey: TEST_IDENTITIES.tyler.pubkey,
|
|
kind: SYSTEM_MESSAGE_KIND,
|
|
targetPubkey: OUT_OF_CHANNEL_PROVIDER_AGENT_PUBKEY,
|
|
},
|
|
);
|
|
|
|
const addedRow = page
|
|
.getByTestId("system-message-row")
|
|
.filter({ hasText: "portal" })
|
|
.filter({ hasText: "added by" });
|
|
const removedRow = page
|
|
.getByTestId("system-message-row")
|
|
.filter({ hasText: "removed portal from the channel" });
|
|
|
|
const addedName = addedRow.getByText("portal", { exact: true });
|
|
await expect(addedName).toBeVisible();
|
|
await expect(addedName).not.toHaveAttribute("data-mention");
|
|
await expect(
|
|
removedRow.locator("[data-mention].agent-mention-highlight", {
|
|
hasText: "portal",
|
|
}),
|
|
).toHaveText("portal");
|
|
});
|
|
|
|
test("groups contiguous arrival activity with hidden names in the standard tooltip", async ({
|
|
page,
|
|
}) => {
|
|
const actor = STANDARD_GROUPED_ARRIVAL_ACTOR;
|
|
const targets = STANDARD_GROUPED_ARRIVAL_TARGETS;
|
|
await installMockBridge(page, {
|
|
searchProfiles: [actor, ...targets],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND);
|
|
|
|
await page.evaluate(
|
|
({ actorPubkey, addedTargets, kind }) => {
|
|
const createdAt = Math.floor(Date.now() / 1_000);
|
|
for (const [index, target] of addedTargets.entries()) {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: JSON.stringify({
|
|
type: "member_joined",
|
|
actor: actorPubkey,
|
|
target: target.pubkey,
|
|
}),
|
|
createdAt: createdAt + index,
|
|
kind,
|
|
});
|
|
}
|
|
},
|
|
{
|
|
actorPubkey: actor.pubkey,
|
|
addedTargets: targets,
|
|
kind: SYSTEM_MESSAGE_KIND,
|
|
},
|
|
);
|
|
await waitForTimelineSettled(page);
|
|
|
|
const groupedRow = page
|
|
.getByTestId("system-message-row")
|
|
.filter({ hasText: "added by Alice Chen, along with" });
|
|
for (const visibleName of [
|
|
"Erica Chapman",
|
|
"Peter Griffin",
|
|
"Marcia Thomas",
|
|
]) {
|
|
await expect(groupedRow).toContainText(visibleName);
|
|
}
|
|
await expect(
|
|
groupedRow.locator("p").filter({ hasText: "added by Alice Chen" }),
|
|
).toContainText(
|
|
"Erica Chapman added by Alice Chen, along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others",
|
|
);
|
|
const avatarStack = groupedRow.getByTestId("system-message-avatar-stack");
|
|
await expect(avatarStack).toHaveCount(1);
|
|
await expect(avatarStack.getByTestId("system-message-avatar")).toHaveCount(5);
|
|
await expect(
|
|
groupedRow.locator("p").filter({ hasText: "added by Alice Chen" }),
|
|
).toHaveCSS("text-align", "left");
|
|
await expect(groupedRow.locator("[data-mention]")).toHaveCount(0);
|
|
|
|
const visibleName = groupedRow.getByText("Peter Griffin", { exact: true });
|
|
await expect(visibleName).toHaveCSS("text-decoration-line", "none");
|
|
await visibleName.hover();
|
|
await expect(visibleName).toHaveCSS("text-decoration-line", "underline");
|
|
|
|
const othersTrigger = groupedRow.getByRole("button", { name: "2 others" });
|
|
// Park the pointer off-target first: the previous hover leaves the mouse at a
|
|
// fixed viewport point, and any later reflow (new rows, scroll-to-bottom, a
|
|
// different text wrap) can slide this button under it. Without this the
|
|
// assertion measures where the mouse happens to be, not the resting style.
|
|
await page.mouse.move(0, 0);
|
|
await expect(othersTrigger).toHaveCSS("text-decoration-line", "none");
|
|
await othersTrigger.hover();
|
|
await expect(othersTrigger).toHaveCSS("text-decoration-line", "underline");
|
|
|
|
const tooltip = page.getByRole("tooltip");
|
|
await expect(tooltip).toContainText("Olivia Park");
|
|
await expect(tooltip).toContainText("Sam Rivera");
|
|
|
|
await expect(avatarStack.locator("..")).toHaveCSS("align-items", "center");
|
|
});
|
|
|
|
test("keeps deterministic grouped-arrival fixture pubkeys unique", () => {
|
|
expect(
|
|
findDuplicateFixturePubkeys([
|
|
STANDARD_GROUPED_ARRIVAL_ACTOR,
|
|
...STANDARD_GROUPED_ARRIVAL_TARGETS,
|
|
...JOIN_COLLAPSE_PROFILES,
|
|
]),
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("collapses contiguous mixed join arrivals into one actor-neutral cohort", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
searchProfiles: JOIN_COLLAPSE_PROFILES,
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText(
|
|
JOIN_COLLAPSE_CHANNEL_NAME,
|
|
);
|
|
await waitForMockLiveSubscription(
|
|
page,
|
|
JOIN_COLLAPSE_CHANNEL_NAME,
|
|
SYSTEM_MESSAGE_KIND,
|
|
);
|
|
|
|
await page.evaluate(
|
|
({ channelName, currentUser, elrond, legolas, gimli, gandalf, kind }) => {
|
|
const createdAt = Math.floor(Date.now() / 1_000);
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName,
|
|
content: JSON.stringify({
|
|
type: "member_joined",
|
|
actor: currentUser,
|
|
target: elrond,
|
|
}),
|
|
createdAt,
|
|
kind,
|
|
pubkey: currentUser,
|
|
});
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName,
|
|
content: JSON.stringify({
|
|
type: "member_joined",
|
|
actor: elrond,
|
|
target: legolas,
|
|
}),
|
|
createdAt: createdAt + 1,
|
|
kind,
|
|
pubkey: elrond,
|
|
});
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName,
|
|
content: JSON.stringify({
|
|
type: "member_joined",
|
|
actor: elrond,
|
|
target: gimli,
|
|
}),
|
|
createdAt: createdAt + 2,
|
|
kind,
|
|
pubkey: elrond,
|
|
});
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName,
|
|
content: JSON.stringify({
|
|
type: "member_joined",
|
|
actor: currentUser,
|
|
target: gandalf,
|
|
}),
|
|
createdAt: createdAt + 3,
|
|
kind,
|
|
pubkey: currentUser,
|
|
});
|
|
},
|
|
{
|
|
channelName: JOIN_COLLAPSE_CHANNEL_NAME,
|
|
currentUser: MOCK_VIEWER_PUBKEY,
|
|
elrond: ELROND_PUBKEY,
|
|
legolas: LEGOLAS_PUBKEY,
|
|
gimli: GIMLI_PUBKEY,
|
|
gandalf: GANDALF_PUBKEY,
|
|
kind: SYSTEM_MESSAGE_KIND,
|
|
},
|
|
);
|
|
await waitForTimelineSettled(page);
|
|
await page.getByTestId("message-timeline").evaluate((element) => {
|
|
element.scrollTop = element.scrollHeight;
|
|
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
|
});
|
|
await waitForAnimations(page);
|
|
|
|
const { rows, texts } = await collectJoinCollapseRows(page);
|
|
console.log(`JOIN_COLLAPSE_VISIBLE_TEXTS=${JSON.stringify(texts)}`);
|
|
if (process.env.JOIN_COLLAPSE_EXPECT_SPLIT === "1") {
|
|
expect(texts).toEqual(JOIN_COLLAPSE_SPLIT_TEXTS);
|
|
}
|
|
await maybeCaptureJoinCollapseTimeline(page);
|
|
|
|
await expect(rows).toHaveCount(1);
|
|
await expect(rows.first()).toContainText(JOIN_COLLAPSE_GROUPED_TEXT);
|
|
});
|
|
|
|
test("system agent avatar keeps keyboard focus decoration outside artwork", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
searchProfiles: [
|
|
{
|
|
pubkey: PROFILE_ONLY_AGENT_PUBKEY,
|
|
displayName: "mira",
|
|
isAgent: true,
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND);
|
|
|
|
await page.evaluate(
|
|
({ actorPubkey, kind }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: JSON.stringify({
|
|
type: "channel_created",
|
|
actor: actorPubkey,
|
|
}),
|
|
kind,
|
|
});
|
|
},
|
|
{
|
|
actorPubkey: PROFILE_ONLY_AGENT_PUBKEY,
|
|
kind: SYSTEM_MESSAGE_KIND,
|
|
},
|
|
);
|
|
await waitForTimelineSettled(page);
|
|
|
|
const row = page
|
|
.getByTestId("system-message-row")
|
|
.filter({ hasText: "created this channel" });
|
|
const control = row.locator('button[data-testid="system-message-avatar"]');
|
|
const artwork = control.getByTestId("system-message-avatar");
|
|
await control.focus();
|
|
|
|
await expect(control).toBeFocused();
|
|
await expect(control).toHaveCSS("clip-path", "none");
|
|
await expect(artwork).toHaveCSS("clip-path", /rounded-squircle-clip/);
|
|
});
|
|
|
|
test("system agent profile exposes owned agent actions", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND);
|
|
|
|
await page.evaluate(
|
|
({ actorPubkey, kind, targetPubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: JSON.stringify({
|
|
type: "member_joined",
|
|
actor: actorPubkey,
|
|
target: targetPubkey,
|
|
}),
|
|
kind,
|
|
});
|
|
},
|
|
{
|
|
actorPubkey: TEST_IDENTITIES.tyler.pubkey,
|
|
kind: SYSTEM_MESSAGE_KIND,
|
|
targetPubkey: PROFILE_ONLY_AGENT_PUBKEY,
|
|
},
|
|
);
|
|
await waitForTimelineSettled(page);
|
|
|
|
const joinedRow = page
|
|
.getByTestId("system-message-row")
|
|
.filter({ hasText: "mira" })
|
|
.filter({ hasText: "added by" });
|
|
const agentName = joinedRow.getByText("mira", { exact: true });
|
|
await expect(agentName).toHaveText("mira");
|
|
await expect(agentName).not.toHaveAttribute("data-mention");
|
|
await agentName.hover();
|
|
|
|
const profilePopover = page.locator(
|
|
'[data-testid="user-profile-popover"][data-state="open"]',
|
|
);
|
|
await expect(profilePopover).toBeVisible();
|
|
await expectOwnedAgentProfileActions(
|
|
profilePopover,
|
|
PROFILE_ONLY_AGENT_PUBKEY,
|
|
);
|
|
});
|
|
|
|
test("system agent activity avatar stack is decorative", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND);
|
|
|
|
await page.evaluate(
|
|
({ kind, targetPubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: JSON.stringify({
|
|
type: "member_joined",
|
|
actor: targetPubkey,
|
|
target: targetPubkey,
|
|
}),
|
|
kind,
|
|
});
|
|
},
|
|
{
|
|
kind: SYSTEM_MESSAGE_KIND,
|
|
targetPubkey: PROFILE_ONLY_AGENT_PUBKEY,
|
|
},
|
|
);
|
|
await waitForTimelineSettled(page);
|
|
|
|
const joinedRow = page
|
|
.getByTestId("system-message-row")
|
|
.filter({ has: page.getByText("mira", { exact: true }) });
|
|
const avatarStack = joinedRow.getByTestId("system-message-avatar-stack");
|
|
await expect(avatarStack.getByTestId("system-message-avatar")).toHaveCount(1);
|
|
await expect(avatarStack.locator("button")).toHaveCount(0);
|
|
});
|
|
|
|
test("membership activity folds a member joining then leaving", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND);
|
|
|
|
await page.evaluate(
|
|
({ alicePubkey, kind }) => {
|
|
const createdAt = Math.floor(Date.now() / 1_000);
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: JSON.stringify({
|
|
type: "member_joined",
|
|
actor: alicePubkey,
|
|
target: alicePubkey,
|
|
}),
|
|
createdAt,
|
|
kind,
|
|
});
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: JSON.stringify({ type: "member_left", actor: alicePubkey }),
|
|
createdAt: createdAt + 1,
|
|
kind,
|
|
});
|
|
},
|
|
{ alicePubkey: TEST_IDENTITIES.alice.pubkey, kind: SYSTEM_MESSAGE_KIND },
|
|
);
|
|
await waitForTimelineSettled(page);
|
|
const lifecycleRow = page
|
|
.getByTestId("system-message-row")
|
|
.filter({ hasText: "alice" })
|
|
.filter({ hasText: "joined, then left the channel" });
|
|
await expect(lifecycleRow).toBeVisible();
|
|
await expect(lifecycleRow.getByTestId("system-message-avatar")).toHaveCount(
|
|
1,
|
|
);
|
|
});
|
|
|
|
test("membership activity folds duplicate self-joins then leaving into one row", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND);
|
|
|
|
// The relay re-emits `member_joined` on each PUT_USER, so a member can arrive
|
|
// twice before leaving. Both arrivals group with the departure; describing
|
|
// only a 2-event pair dropped the departure and left the member rendered as
|
|
// still present.
|
|
await page.evaluate(
|
|
({ alicePubkey, kind }) => {
|
|
const createdAt = Math.floor(Date.now() / 1_000);
|
|
const join = {
|
|
type: "member_joined",
|
|
actor: alicePubkey,
|
|
target: alicePubkey,
|
|
};
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: JSON.stringify(join),
|
|
createdAt,
|
|
kind,
|
|
});
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: JSON.stringify(join),
|
|
createdAt: createdAt + 1,
|
|
kind,
|
|
});
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: JSON.stringify({ type: "member_left", actor: alicePubkey }),
|
|
createdAt: createdAt + 2,
|
|
kind,
|
|
});
|
|
},
|
|
{ alicePubkey: TEST_IDENTITIES.alice.pubkey, kind: SYSTEM_MESSAGE_KIND },
|
|
);
|
|
await waitForTimelineSettled(page);
|
|
|
|
const aliceRows = page
|
|
.getByTestId("system-message-row")
|
|
.filter({ hasText: "alice" });
|
|
await expect(aliceRows).toHaveCount(1);
|
|
await expect(aliceRows).toHaveText(/joined, then left the channel/);
|
|
await expect(aliceRows.getByTestId("system-message-avatar")).toHaveCount(1);
|
|
});
|
|
|
|
test("profile-only agent author hides actions without agent access", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
searchProfiles: [
|
|
{
|
|
pubkey: PROFILE_ONLY_AGENT_PUBKEY,
|
|
displayName: "mira",
|
|
isAgent: true,
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "general");
|
|
|
|
await emitMockMessage(page, "general", "Mira status update.", {
|
|
pubkey: PROFILE_ONLY_AGENT_PUBKEY,
|
|
});
|
|
await waitForTimelineSettled(page);
|
|
|
|
const messageRow = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Mira status update." })
|
|
.first();
|
|
await messageRow.locator("button").first().hover();
|
|
|
|
const profilePopover = page.locator(
|
|
'[data-testid="user-profile-popover"][data-state="open"]',
|
|
);
|
|
await expect(profilePopover).toBeVisible();
|
|
await expectAgentProfileActionsHidden(
|
|
profilePopover,
|
|
PROFILE_ONLY_AGENT_PUBKEY,
|
|
);
|
|
});
|
|
|
|
test("system member-joined rows render the joined person as a plain profile name", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "general", SYSTEM_MESSAGE_KIND);
|
|
|
|
await page.evaluate(
|
|
({ kind, pubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "general",
|
|
content: JSON.stringify({
|
|
type: "member_joined",
|
|
actor: pubkey,
|
|
target: pubkey,
|
|
}),
|
|
kind,
|
|
});
|
|
},
|
|
{ kind: SYSTEM_MESSAGE_KIND, pubkey: TEST_IDENTITIES.bob.pubkey },
|
|
);
|
|
await waitForTimelineSettled(page);
|
|
|
|
const joinedRow = page
|
|
.getByTestId("system-message-row")
|
|
.filter({ has: page.getByText("bob", { exact: true }) });
|
|
const joinedPersonName = joinedRow.getByText("bob", { exact: true });
|
|
|
|
await expect(joinedPersonName).toBeVisible();
|
|
await expect(joinedPersonName).not.toHaveAttribute("data-mention");
|
|
});
|
|
|
|
test("a managed non-member agent from a DM can be addressed explicitly", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: TEST_IDENTITIES.charlie.pubkey,
|
|
name: "charlie",
|
|
status: "stopped",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-bob-tyler").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@char");
|
|
|
|
const dropdown = autocomplete(page);
|
|
const charlieRow = dropdown.getByTestId(
|
|
`mention-suggestion-${TEST_IDENTITIES.charlie.pubkey}`,
|
|
);
|
|
await expect(charlieRow.getByText("charlie")).toBeVisible();
|
|
await expect(autocomplete(page)).toHaveCount(1);
|
|
await expect(input.locator(".mention-chip")).toHaveCount(0);
|
|
await charlieRow
|
|
.getByRole("button", {
|
|
name: "Mention charlie",
|
|
exact: true,
|
|
})
|
|
.click();
|
|
|
|
await expect(input).toHaveText("@charlie ");
|
|
await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie");
|
|
await expect(
|
|
page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("global non-member people can be selected from channel mentions", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Loop in @out");
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown.getByText("outsider")).toBeVisible();
|
|
await expect(dropdown.getByText("not in channel")).toBeVisible();
|
|
});
|
|
|
|
test("duplicate global people with the same visible identity collapse in channel mentions", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
searchProfiles: [
|
|
{
|
|
pubkey: CASEY_PROFILE_PUBKEY,
|
|
displayName: "Pip",
|
|
},
|
|
{
|
|
pubkey:
|
|
"2222222222222222222222222222222222222222222222222222222222222222",
|
|
displayName: "Pip",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@pip");
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown.locator("button", { hasText: "Pip" })).toHaveCount(1);
|
|
});
|
|
|
|
test("sent non-member person mention uses the normal mention style", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-bob-tyler").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Loop in @out");
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown.getByText("outsider")).toBeVisible();
|
|
await input.press("Enter");
|
|
await page.keyboard.type(" please");
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const mentionChip = page
|
|
.getByTestId("message-row")
|
|
.last()
|
|
.locator("[data-mention]", { hasText: "outsider" });
|
|
await expect(mentionChip).toBeVisible();
|
|
await expect(mentionChip).toHaveClass(/inline-chip-icon-human/);
|
|
});
|
|
|
|
test("sent managed non-member agent mention uses the agent mention style", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: TEST_IDENTITIES.charlie.pubkey,
|
|
name: "charlie",
|
|
status: "stopped",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-bob-tyler").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Loop in @char");
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown.getByText("charlie")).toBeVisible();
|
|
await input.press("Enter");
|
|
await page.keyboard.type(" too");
|
|
await page.getByTestId("send-message").click();
|
|
|
|
const mentionChip = page
|
|
.getByTestId("message-row")
|
|
.last()
|
|
.locator("[data-mention]", { hasText: "charlie" });
|
|
await expect(mentionChip).toBeVisible();
|
|
await expect(mentionChip).toHaveText("charlie");
|
|
await expect(mentionChip).toHaveClass(/agent-mention-highlight/);
|
|
});
|
|
|
|
test("mention button opens autocomplete and inserts a selected member", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Hey ");
|
|
await page.getByTestId("message-insert-mention").click();
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown).toBeVisible();
|
|
await dropdown.getByText("bob").click();
|
|
|
|
await expect(input).toHaveText("Hey @bob ");
|
|
});
|
|
|
|
test("inserting a mention preserves Shift+Enter newlines (regression: bug #2)", async ({
|
|
page,
|
|
}) => {
|
|
// Before PR #618, mention insertion round-tripped through
|
|
// `setContent(markdown)`, which collapsed every Shift+Enter hard
|
|
// break to a single space. After the fix, autocomplete uses a
|
|
// native ProseMirror `tr.insertText` transaction and the line
|
|
// breaks survive.
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.click();
|
|
await page.keyboard.type("line one");
|
|
await page.keyboard.press("Shift+Enter");
|
|
await page.keyboard.type("line two @bo");
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown.getByText("bob")).toBeVisible();
|
|
await dropdown.getByText("bob").click();
|
|
|
|
// Both lines must still be present, separated by a real line break
|
|
// (rendered as a `<br>` by Tiptap; the projection sees `\n`).
|
|
await expect(input).toHaveText(/line one[\s\S]*line two @bob/);
|
|
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");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@bo");
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown.getByText("bob")).toBeVisible();
|
|
|
|
// Press Enter to select the first (and only) suggestion
|
|
await input.press("Enter");
|
|
|
|
// Should insert @bob and NOT send the message
|
|
await expect(input).toHaveText("@bob ");
|
|
});
|
|
|
|
test("Escape dismisses autocomplete dropdown", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("@");
|
|
|
|
const dropdown = autocomplete(page);
|
|
await expect(dropdown).toBeVisible();
|
|
|
|
await input.press("Escape");
|
|
|
|
await expect(dropdown).not.toBeVisible();
|
|
});
|
|
|
|
test("mention text is highlighted in sent messages", async ({ page }) => {
|
|
const suffix = ` check this out ${Date.now()}`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.fill("Hey @bo");
|
|
await autocomplete(page).getByText("bob").click();
|
|
await expect(input).toHaveText("Hey @bob ");
|
|
await page.keyboard.type(suffix);
|
|
await page.getByTestId("send-message").click();
|
|
|
|
await waitForTimelineSettled(page);
|
|
|
|
const mentionChip = page
|
|
.getByTestId("message-row")
|
|
.last()
|
|
.locator("[data-mention].mention-chip", { hasText: "bob" });
|
|
await expect(mentionChip).toBeVisible();
|
|
await expect(mentionChip).toHaveText("bob");
|
|
await expect(mentionChip).toHaveClass(/inline-chip-icon-human/);
|
|
await expect(mentionChip).toHaveClass(/wrapping-inline-chip/);
|
|
|
|
const timelineLayout = await timelineChipLayout(mentionChip);
|
|
expect(timelineLayout).toMatchObject({
|
|
boxDecorationBreak: "clone",
|
|
chipHeight: 17,
|
|
chipLineHeight: 18,
|
|
fragmentCount: 1,
|
|
fragmentGap: null,
|
|
fragmentHeight: 17,
|
|
fragmentStep: null,
|
|
paragraphHeight: 20,
|
|
paragraphLineHeight: 20,
|
|
});
|
|
});
|
|
|
|
test("qualified mentions wrap without changing message line rhythm", async ({
|
|
page,
|
|
}) => {
|
|
const pubkey = TEST_IDENTITIES.bob.pubkey;
|
|
const qualifiedLabel = `bob (${pubkey})`;
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "general");
|
|
await emitMockMessage(page, "general", `@${qualifiedLabel}`, {
|
|
mentionPubkeys: [pubkey],
|
|
});
|
|
await waitForTimelineSettled(page);
|
|
|
|
const row = page.getByTestId("message-row").filter({ hasText: "bob" }).last();
|
|
await row.evaluate((element) => {
|
|
const prose = element.querySelector<HTMLElement>(".message-markdown");
|
|
if (!prose)
|
|
throw new Error("Qualified mention is missing its prose wrapper");
|
|
prose.style.width = "8rem";
|
|
});
|
|
const mentionChip = row.locator("[data-mention]", { hasText: "bob" });
|
|
await expect(mentionChip).toHaveText(/bob \(npub1hv3…tpuc\)/);
|
|
await expect(mentionChip).toHaveClass(/wrapping-inline-chip/);
|
|
|
|
const layout = await timelineChipLayout(mentionChip);
|
|
expect(layout.boxDecorationBreak).toBe("clone");
|
|
expect(layout.chipLineHeight).toBe(18);
|
|
expect(layout.fragmentCount).toBe(2);
|
|
expect(layout.fragmentHeight).toBe(17);
|
|
expect(layout.fragmentGap).toBeGreaterThanOrEqual(1);
|
|
expect(layout.fragmentStep).toBe(20);
|
|
expect(layout.paragraphLineHeight).toBe(20);
|
|
expect(layout.chipHeight).toBeLessThanOrEqual(
|
|
layout.fragmentCount * layout.paragraphLineHeight,
|
|
);
|
|
|
|
const trigger = mentionChip.locator("xpath=..");
|
|
await expect(trigger).toHaveCSS("display", "inline");
|
|
await expect(trigger).toHaveAttribute("role", "button");
|
|
await trigger.focus();
|
|
await expect(trigger).toBeFocused();
|
|
await trigger.press("Enter");
|
|
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
|
});
|
|
|
|
test("clicking author name opens user profile panel", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// The seed message in general is from the mock identity (npub1mock...)
|
|
const firstMessage = page.getByTestId("message-row").first();
|
|
const authorButton = firstMessage.locator("button", {
|
|
hasText: "npub1mock...",
|
|
});
|
|
await authorButton.click();
|
|
|
|
// Click now opens the full profile panel instead of the popover
|
|
const panel = page.getByTestId("user-profile-panel");
|
|
await expect(panel).toBeVisible();
|
|
await expect(panel).toContainText(truncateNpub(MOCK_VIEWER_PUBKEY));
|
|
await expect(panel).not.toContainText("deadbeefdeadbeef");
|
|
});
|
|
|
|
test("hovering avatar opens popover, clicking opens profile panel", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const firstMessage = page.getByTestId("message-row").first();
|
|
const avatarButton = firstMessage.locator("button").first();
|
|
|
|
// Hover should open the popover
|
|
await avatarButton.hover();
|
|
const profilePopover = page.locator(
|
|
'[data-testid="user-profile-popover"][data-state="open"]',
|
|
);
|
|
await expect(profilePopover).toBeVisible();
|
|
|
|
// Click should close the popover and open the profile panel
|
|
await avatarButton.click();
|
|
await expect(profilePopover).toHaveCount(0);
|
|
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
|
});
|
|
|
|
test("clicking a mention chip in the timeline opens the profile panel", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "general");
|
|
|
|
await emitMockMessage(page, "general", "Ping @bob about the launch", {
|
|
mentionPubkeys: [TEST_IDENTITIES.bob.pubkey],
|
|
});
|
|
await waitForTimelineSettled(page);
|
|
|
|
const mentionChip = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Ping bob about the launch" })
|
|
.locator("[data-mention]", { hasText: "bob" });
|
|
await expect(mentionChip).toBeVisible();
|
|
await mentionChip.click();
|
|
|
|
const panel = page.getByTestId("user-profile-panel");
|
|
await expect(panel).toBeVisible();
|
|
await expect(panel).toContainText("bob");
|
|
});
|
|
|
|
test("mention text matching the kind-0 name alias resolves and opens the profile panel", async ({
|
|
page,
|
|
}) => {
|
|
// bob's mock profile has display_name "bob" and kind-0 name "bobby". A
|
|
// message that says "@bobby" (how agents/CLI resolve mentions at send time)
|
|
// must still render a clickable chip bound to bob's pubkey.
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "general");
|
|
|
|
await emitMockMessage(page, "general", "Ask @bobby to review the doc", {
|
|
mentionPubkeys: [TEST_IDENTITIES.bob.pubkey],
|
|
});
|
|
await waitForTimelineSettled(page);
|
|
|
|
const mentionChip = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Ask bobby to review the doc" })
|
|
.locator("[data-mention]", { hasText: "bobby" });
|
|
await expect(mentionChip).toBeVisible();
|
|
await mentionChip.click();
|
|
|
|
const panel = page.getByTestId("user-profile-panel");
|
|
await expect(panel).toBeVisible();
|
|
await expect(panel).toContainText("bob");
|
|
});
|
|
|
|
test("clicking a mention chip in a forum post opens the profile panel", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
// Seed the forum post before entering the channel — forum views load from
|
|
// the mock store on fetch, so no live subscription is needed.
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await emitMockMessage(page, "watercooler", "Welcome aboard @bob!", {
|
|
kind: 45001,
|
|
mentionPubkeys: [TEST_IDENTITIES.bob.pubkey],
|
|
});
|
|
|
|
await page.getByTestId("channel-watercooler").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
|
|
|
|
const mentionChip = page.locator("[data-mention]", { hasText: "bob" });
|
|
await expect(mentionChip).toBeVisible();
|
|
await mentionChip.click();
|
|
|
|
const panel = page.getByTestId("user-profile-panel");
|
|
await expect(panel).toBeVisible();
|
|
await expect(panel).toContainText("bob");
|
|
// The chip click must not bubble into the card and open the thread view.
|
|
await expect(page.getByRole("button", { name: "Back to posts" })).toHaveCount(
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("agent profile popover shows its owner", async ({ page }) => {
|
|
await installMockBridge(page, {
|
|
searchProfiles: [
|
|
{
|
|
pubkey: OWNED_AGENT_PROFILE_PUBKEY,
|
|
displayName: "Pollen",
|
|
ownerPubkey: TEST_IDENTITIES.bob.pubkey,
|
|
isAgent: true,
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "general");
|
|
|
|
await emitMockMessage(page, "general", "Pollen checking in.", {
|
|
pubkey: OWNED_AGENT_PROFILE_PUBKEY,
|
|
});
|
|
await waitForTimelineSettled(page);
|
|
|
|
const pollenMessage = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Pollen checking in." })
|
|
.first();
|
|
await pollenMessage.locator("button").first().hover();
|
|
|
|
const profilePopover = page.locator(
|
|
'[data-testid="user-profile-popover"][data-state="open"]',
|
|
);
|
|
await expect(profilePopover).toBeVisible();
|
|
await expect(
|
|
profilePopover.getByTestId(
|
|
`user-profile-popover-owner-${OWNED_AGENT_PROFILE_PUBKEY}`,
|
|
),
|
|
).toHaveText("managed by bob");
|
|
});
|
|
|
|
test("agent profile popover labels an agent owned by the viewer as you", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
searchProfiles: [
|
|
{
|
|
pubkey: OWNED_AGENT_PROFILE_PUBKEY,
|
|
displayName: "Pollen",
|
|
ownerPubkey: MOCK_VIEWER_PUBKEY,
|
|
isAgent: true,
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "general");
|
|
|
|
await emitMockMessage(page, "general", "Pollen checking in.", {
|
|
pubkey: OWNED_AGENT_PROFILE_PUBKEY,
|
|
});
|
|
await waitForTimelineSettled(page);
|
|
|
|
const pollenMessage = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Pollen checking in." })
|
|
.first();
|
|
await pollenMessage.locator("button").first().hover();
|
|
|
|
const profilePopover = page.locator(
|
|
'[data-testid="user-profile-popover"][data-state="open"]',
|
|
);
|
|
await expect(profilePopover).toBeVisible();
|
|
await expect(
|
|
profilePopover.getByTestId(
|
|
`user-profile-popover-owner-${OWNED_AGENT_PROFILE_PUBKEY}`,
|
|
),
|
|
).toHaveText("managed by you");
|
|
});
|
|
|
|
test("agent profile popover falls back to the owner's pubkey", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
searchProfiles: [
|
|
{
|
|
pubkey: OWNED_AGENT_PROFILE_PUBKEY,
|
|
displayName: "Pollen",
|
|
ownerPubkey: CASEY_PROFILE_PUBKEY,
|
|
isAgent: true,
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "general");
|
|
|
|
await emitMockMessage(page, "general", "Pollen checking in.", {
|
|
pubkey: OWNED_AGENT_PROFILE_PUBKEY,
|
|
});
|
|
await waitForTimelineSettled(page);
|
|
|
|
const pollenMessage = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Pollen checking in." })
|
|
.first();
|
|
await pollenMessage.locator("button").first().hover();
|
|
|
|
const profilePopover = page.locator(
|
|
'[data-testid="user-profile-popover"][data-state="open"]',
|
|
);
|
|
await expect(profilePopover).toBeVisible();
|
|
await expect(
|
|
profilePopover.getByTestId(
|
|
`user-profile-popover-owner-${OWNED_AGENT_PROFILE_PUBKEY}`,
|
|
),
|
|
).toHaveText(`managed by ${truncateNpub(CASEY_PROFILE_PUBKEY)}`);
|
|
});
|
|
|
|
test("human profile popover does not show an owner", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "general");
|
|
|
|
await emitMockMessage(page, "general", "Bob checking in.", {
|
|
pubkey: TEST_IDENTITIES.bob.pubkey,
|
|
});
|
|
await waitForTimelineSettled(page);
|
|
|
|
const bobMessage = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Bob checking in." })
|
|
.first();
|
|
await bobMessage.locator("button").first().hover();
|
|
|
|
const profilePopover = page.locator(
|
|
'[data-testid="user-profile-popover"][data-state="open"]',
|
|
);
|
|
await expect(profilePopover).toBeVisible();
|
|
await expect(
|
|
profilePopover.locator('[data-testid^="user-profile-popover-owner-"]'),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("owned bot profile exposes message and huddle actions", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: TEST_IDENTITIES.charlie.pubkey,
|
|
name: "charlie",
|
|
status: "online",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-agents").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("agents");
|
|
|
|
const charlieMessage = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Indexing the channel catalog now." })
|
|
.first();
|
|
await charlieMessage.locator("button").first().hover();
|
|
|
|
const profilePopover = page.locator(
|
|
'[data-testid="user-profile-popover"][data-state="open"]',
|
|
);
|
|
await expect(profilePopover).toBeVisible();
|
|
await expectOwnedAgentProfileActions(
|
|
profilePopover,
|
|
TEST_IDENTITIES.charlie.pubkey,
|
|
);
|
|
|
|
await profilePopover
|
|
.getByTestId(
|
|
`user-profile-popover-huddle-${TEST_IDENTITIES.charlie.pubkey}`,
|
|
)
|
|
.click();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() =>
|
|
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).find(
|
|
(entry) => entry.command === "start_huddle",
|
|
)?.payload,
|
|
),
|
|
)
|
|
.toMatchObject({ memberPubkeys: [TEST_IDENTITIES.charlie.pubkey] });
|
|
});
|
|
|
|
test("owned agent mention profile exposes message and huddle actions", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey: TEST_IDENTITIES.charlie.pubkey,
|
|
name: "charlie",
|
|
status: "online",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "general");
|
|
|
|
await emitMockMessage(page, "general", "Can @charlie take this?", {
|
|
mentionPubkeys: [TEST_IDENTITIES.charlie.pubkey],
|
|
});
|
|
await waitForTimelineSettled(page);
|
|
|
|
const mentionChip = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Can charlie take this?" })
|
|
.locator("[data-mention].agent-mention-highlight", { hasText: "charlie" });
|
|
await expect(mentionChip).toBeVisible();
|
|
await mentionChip.hover();
|
|
|
|
const profilePopover = page.locator(
|
|
'[data-testid="user-profile-popover"][data-state="open"]',
|
|
);
|
|
await expect(profilePopover).toBeVisible();
|
|
await expectOwnedAgentProfileActions(
|
|
profilePopover,
|
|
TEST_IDENTITIES.charlie.pubkey,
|
|
);
|
|
});
|
|
|
|
test("profile popover wave sends a direct message for a human profile", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { sendMessageDelayMs: 2_500 });
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "general");
|
|
|
|
await emitMockMessage(page, "general", "Bob says hello.", {
|
|
pubkey: TEST_IDENTITIES.bob.pubkey,
|
|
});
|
|
await waitForTimelineSettled(page);
|
|
|
|
const bobMessage = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Bob says hello." })
|
|
.first();
|
|
await bobMessage.locator("button").first().hover();
|
|
|
|
const profilePopover = page.locator(
|
|
'[data-testid="user-profile-popover"][data-state="open"]',
|
|
);
|
|
await expect(profilePopover).toBeVisible();
|
|
await expect(
|
|
profilePopover.getByTestId(
|
|
`user-profile-popover-message-${TEST_IDENTITIES.bob.pubkey}`,
|
|
),
|
|
).toBeVisible();
|
|
await expect(
|
|
profilePopover.getByTestId(
|
|
`user-profile-popover-huddle-${TEST_IDENTITIES.bob.pubkey}`,
|
|
),
|
|
).toBeVisible();
|
|
await expect(
|
|
profilePopover.getByTestId(
|
|
`user-profile-popover-wave-${TEST_IDENTITIES.bob.pubkey}`,
|
|
),
|
|
).toBeVisible();
|
|
await profilePopover
|
|
.getByTestId(`user-profile-popover-wave-${TEST_IDENTITIES.bob.pubkey}`)
|
|
.click();
|
|
|
|
await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler");
|
|
const waveAttachment = page.getByTestId("message-wave-attachment");
|
|
await expect(waveAttachment).toBeVisible({ timeout: 1_500 });
|
|
await expect(page.getByText("Sending")).toHaveCount(0, { timeout: 4_000 });
|
|
await waitForTimelineSettled(page);
|
|
await expect(waveAttachment).toContainText("👋");
|
|
await expect(waveAttachment).toContainText("npub1mock... waved at you.");
|
|
await expect(waveAttachment).toContainText("Start a huddle to talk to them.");
|
|
await expect(
|
|
waveAttachment.getByRole("button", { name: "Start huddle" }),
|
|
).toBeVisible();
|
|
|
|
const commandLog = await readCommandPayloadLog(page);
|
|
expect(commandLog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
command: "send_channel_message",
|
|
payload: expect.objectContaining({
|
|
content: expect.stringContaining("npub1mock... waved at you."),
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
expect(commandLog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
command: "send_channel_message",
|
|
payload: expect.objectContaining({
|
|
content: expect.stringContaining("<!-- buzz:wave:v1 -->"),
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
test("delayed inaccessible agent profile keeps all actions hidden", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
agentListDelayMs: 5_000,
|
|
relayAgents: [
|
|
{
|
|
pubkey: DELAYED_RELAY_AGENT_PUBKEY,
|
|
name: "orbit",
|
|
channelNames: ["general"],
|
|
},
|
|
],
|
|
searchProfiles: [
|
|
{
|
|
pubkey: DELAYED_RELAY_AGENT_PUBKEY,
|
|
displayName: "orbit",
|
|
},
|
|
],
|
|
});
|
|
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "general");
|
|
|
|
await emitMockMessage(page, "general", "Orbit checking in.", {
|
|
pubkey: DELAYED_RELAY_AGENT_PUBKEY,
|
|
});
|
|
await waitForTimelineSettled(page);
|
|
|
|
const orbitMessage = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Orbit checking in." })
|
|
.first();
|
|
await orbitMessage.locator("button").first().hover();
|
|
|
|
const profilePopover = page.locator(
|
|
'[data-testid="user-profile-popover"][data-state="open"]',
|
|
);
|
|
await expect(profilePopover).toBeVisible();
|
|
await expect(
|
|
profilePopover.getByTestId(
|
|
`user-profile-popover-message-${DELAYED_RELAY_AGENT_PUBKEY}`,
|
|
),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
profilePopover.getByTestId(
|
|
`user-profile-popover-wave-${DELAYED_RELAY_AGENT_PUBKEY}`,
|
|
),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
profilePopover.getByTestId(
|
|
`user-profile-popover-huddle-${DELAYED_RELAY_AGENT_PUBKEY}`,
|
|
),
|
|
).toHaveCount(0);
|
|
});
|