mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
🤖 ## Summary Every Buzz account is identified by a long public key. Before this change, when someone had no display name, surfaces fell back to inconsistent labels — mostly raw hex fragments like `abcd1234…wxyz`, sometimes a generic role label with no key — so the same person looked different from surface to surface, and nothing looked like an npub address. This PR applies the npub identity foundation from #7488 to the everyday surfaces: a person without a display name now falls back to the same compact npub everywhere — `npub1xxxx…yyyy`, the human-readable spelling of their public key (first 8 + last 4 characters of the full npub) — across messages and mentions, reactions, huddles, member and participant lists, the sidebar and channel activity, search, projects, tray, notifications, and workflow surfaces. - **Mentions and messages**: key-only mention chips render the compact npub. Pasting a copied mention back still re-binds it byte-exactly to the identity it declares, for both the new npub chips and legacy hex-truncated chips copied by older clients — wrong, missing, or tampered key qualification is rejected instead of silently degrading to plain text. - **Reactions and huddles**: huddle reaction events and the huddle roster/participants render the compact npub for unnamed participants; workflow reaction triggers describe authors with the same form. - **Members and sidebar**: channel and community member lists, add-member results and invites, the members sidebar, the channel-activity popover, search, projects (assignees/reviewers/PR panels), the tray menu, and desktop notifications all fall back to the compact npub; titles and aria labels keep the machine-readable full labels. - **Profile labels**: panel/popover display names and owner handles fall back to the compact npub (never raw hex) when there is no name; linked-event (nevent) message metadata shows the npub-shaped author fallback while the event lookup and event IDs are unchanged. - **Workflows**: author-picker secondary labels, step destination keys, and trigger-author references render compact npubs; event and blob IDs keep their existing hex compacts (they are not identities). - **Avatars stay distinct**: fallback avatars for key-only identities derive initials from the key's tail, so prefixed role labels like "Participant npub1…" no longer collapse every unnamed participant onto the same initials; people with names keep their name initials. Preserved exactly: display names and distinct avatars, internal hex keys (storage/API forms unchanged), clipboard identity roundtrips, event/blob ID compaction, private keys (no nsec path is touched), and nevent link handling. Scope: this PR changes what identity labels **display**, not identity controls — profile/settings copy controls, the respond-to allowlist, workflow key fields, and agent dialogs are the sibling slice #7489, and the shared primitives (`canonicalNpub`, `truncateNpub`, the `<PubKey>` gate, strict input parsing) come from the foundation #7488. ### Related issue - Fixes: N/A. Searched existing issues/PRs for duplicates — none found; the related work is the npub identity stack this slice belongs to. - Base/dependency: stacks on #7488 (foundation) — this PR does not stand alone on main. - #7489 is a sibling slice on the same #7488 base (profile/agent/workflow controls), not a dependency: this PR does not require #7489, and #7489 does not require this PR — both only require #7488. ### Testing At exact head `4763cbeae1dd521309755e6d61f657324cb98667` (base: `fix/desktop-npub-identity-d1a` @ `5f3a4a8111998c8aa41ad77cf66992bd1c85343c`; 71 files, +656/−189 — production +277/−136, test support +379/−53): - At this head: targeted `mentions.spec.ts` (1/1), the e2e build, typecheck, and biome — green. - 9 changed/related unit files: 100/100 green; typecheck, e2e build, biome, and px text/truncation checks clean; huddle-roster focused run green; channel-activity e2e 11/11; mutation checks confirm the fallback wiring (removing it collapses shared initials and drops fallback rows). - Known pre-existing local e2e failures, unchanged by this PR and reproduced identically at the upstream merge-base: huddle-transcription voice-menu attribution (25 pass / 1 fail) and the `workflow-local-controls` 438px caret drift. Not claimed green locally. - Update at head `236af9e6137386737e84d3a474d6bc808a704c50` (test-only follow-ups `1143af345` + `236af9e6`): the `workflow-local-controls` races were fixed in the test drivers, and the 438px diff was shown to be a stale Darwin snapshot baseline (name-row enable switch already absent and `message_posted` already MessageSquare at recording commit `9390e11c9`) and refreshed — the focused screenshot test, including keyboard/caret assertions, now passes locally (twice). The full spec was not rerun after the snapshot refresh; the huddle-transcription item above is unchanged. Label/copy text changes are asserted by the e2e specs (`mentions`, `mention-recipients`, `pubkey-display-screenshots`, `huddle-transcription`, `channel-activity-popover`, `workflow-local-controls`) rather than new screenshots; the screenshot spec pins the compact npub text forms. ### Task provenance Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9` Task: buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6 --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
5013 lines
165 KiB
TypeScript
5013 lines
165 KiB
TypeScript
import { expect, test } 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;
|
|
|
|
/** 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");
|
|
});
|
|
|
|
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 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/);
|
|
});
|
|
|
|
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);
|
|
});
|