Files
buzz/desktop/tests/e2e/forum-agent-invitation.spec.ts
Logan Johnson e7e29937a1 feat(desktop): invite owned agents from standalone forums (#7125)
## Summary

In Desktop's standalone Forums, selecting an owned agent from another
device could leave a post or reply unsendable if the agent had not
joined the forum. This adds **Invite / Cancel** to the send flow so you
can resolve membership without leaving your draft.

- **Invite** checks response policy and your permission to add members,
adds the agent to the forum, waits for refreshed membership, then
rechecks authorization before posting to the original destination.
Membership is forum-wide, not limited to one post.
- **Cancel / Escape** keeps the text, attachments and selected
recipients for retry. Unlike chat's **Do nothing / Send anyway**, this
dialog has no reference-only send choice. Invite is disabled while
pending; Cancel remains available.
- Leaving the source post/reply cancels its pending invitation, even if
you return. Errors remain visible, focus returns to the initiating
editor when appropriate, and late completion cannot resume a cancelled
post or interfere with a newer attempt.
- A rejected send restores text, uploaded media and exact selected
recipients to the source draft only if no newer edit, deletion, upload
intent or send supersedes it. Clipboard verification settles before
recipient capture, with stale edits/navigation fenced out.

### Related issue

Targets `main` after #7124 merged. This PR reuses its publication checks
and draft protection; the five forum commits have been replayed
unchanged onto the merged parent. Owned-agent discovery (#7122) is
already merged. Split from #7114.

Forum creation/templates, channel-less Notes and local-agent management
are unchanged. Duplicate-name binding from #7133 is already merged and
retained by this stack. Inviting does not start a remote agent or
promise that it is online or will reply.

### Testing

Forum composer lifecycle tests, including clipboard-settlement cases,
and TypeScript/changed-file formatting checks passed after the restack.
Earlier invitation, focus and transport-recovery browser coverage is
retained, not claimed as a fresh full browser run on this head. See
[live CI](https://github.com/block/buzz/pull/7125/checks) for
current-head results. Browser evidence uses mock IPC; no full local
`just ci` pass or native/live-relay validation is claimed.

To try it: open a forum post or reply, select an owned nonmember agent
and send. Cancel, then retry without reselecting; Invite should add that
agent before posting. Deny the add to check the visible error and
retained draft. Navigate away/back during a pending invitation or
rejected send; no stale publication or overwrite of a newer draft should
occur.

![Forum invitation with Invite and
Cancel](https://raw.githubusercontent.com/block/buzz/6481bd088d661975672addcc834231df6ac88705/pr-7125--forum-invite.png)

*Earlier mock-browser capture, not current-head runtime proof. [Error
and successful-post
captures](https://github.com/block/buzz/pull/7125#issuecomment-5513883462);
no before-state/native capture available.*

**Limits:** cancellation cannot undo accepted membership changes or
dispatched posts; authorization and publication are not atomic. Recovery
is same-window, subject to browser storage limits, and is not a durable
in-flight send journal: reload/crash can lose a pending snapshot.
Cross-window coordination and in-flight upload custody are unchanged.
The parent's legacy member-agent compatibility does not establish
ownership for nonmember invitations.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-04 17:33:30 +00:00

641 lines
22 KiB
TypeScript

import { waitForAnimations } from "../helpers/animations";
import { expect, test, type Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const OWNER = "deadbeef".repeat(8);
const REMOTE = "ed".repeat(32);
async function install(
page: Page,
overrides: Parameters<typeof installMockBridge>[1] = {},
) {
await installMockBridge(page, {
...overrides,
ownerOnlyAccessBuild: true,
managedAgents: [],
searchProfiles: [
{
pubkey: REMOTE,
displayName: "RemoteScout",
ownerPubkey: OWNER,
isAgent: true,
},
],
relayAgents: [
{
pubkey: REMOTE,
name: "RemoteScout",
ownerPubkey: OWNER,
respondTo: "allowlist",
respondToAllowlist: [],
channelNames: [],
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
}
async function select(page: Page) {
await page.getByTestId("message-input").fill("@Remote");
const row = page.getByTestId(`mention-suggestion-${REMOTE}`);
await expect(row).toContainText("RemoteScout");
await row.locator("button").first().click();
await page.keyboard.type("hello");
}
async function sent(page: Page) {
return page.evaluate(() => {
const signed = (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? [])
.filter((event) => event.content === "@RemoteScout hello")
.map((event) =>
event.tags.filter((tag) => tag[0] === "p").map((tag) => tag[1]),
);
if (signed.length) return signed;
// New DMs deliberately use the acknowledged native HTTP command rather
// than JS sign_event. Assert its exact outgoing recipients, not fake crypto.
return (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).flatMap((call) => {
const payload = call.payload as {
content?: string;
mentionPubkeys?: string[];
};
return call.command === "send_channel_message" &&
payload.content === "@RemoteScout hello"
? [payload.mentionPubkeys ?? []]
: [];
});
});
}
async function assertNoLocalLifecycle(page: Page) {
const commands = await page.evaluate(
() => window.__BUZZ_E2E_COMMANDS__ ?? [],
);
for (const command of [
"start_managed_agent",
"create_managed_agent",
"attach_managed_agent",
]) {
expect(commands).not.toContain(command);
}
}
const FORUM = "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11";
async function openStandaloneForumInvite(page: Page) {
await install(page);
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 select(page);
await page.getByTestId("send-message").click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toContainText("RemoteScout");
await expect(
dialog.getByRole("button", { name: "Invite", exact: true }),
).toBeVisible();
await expect(
dialog.getByRole("button", { name: "Cancel", exact: true }),
).toBeFocused();
expect(await sent(page)).toEqual([]);
return dialog;
}
async function forumAdds(page: Page) {
return page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
(call) => call.command === "add_channel_members",
),
);
}
test("standalone forum explicitly invites, refreshes membership, then sends exact p-tags", async ({
page,
}) => {
const dialog = await openStandaloneForumInvite(page);
expect(await forumAdds(page)).toEqual([]);
await waitForAnimations(page);
await page.screenshot({ path: "test-results/forum-invite.png" });
// Unlike chat's explicit reference-only option, cancel must not publish.
await expect(
dialog.getByRole("button", { name: /Do nothing|Send anyway/ }),
).toHaveCount(0);
await dialog.getByRole("button", { name: "Invite", exact: true }).click();
await expect.poll(() => sent(page)).toEqual([[REMOTE]]);
await waitForAnimations(page);
await page.screenshot({ path: "test-results/forum-sent.png" });
expect(await forumAdds(page)).toEqual([
expect.objectContaining({
payload: expect.objectContaining({
channelId: FORUM,
pubkeys: [REMOTE],
role: "bot",
}),
}),
]);
const calls = await page.evaluate(
() => window.__BUZZ_E2E_COMMAND_LOG__ ?? [],
);
const addIndex = calls.findIndex(
(call) => call.command === "add_channel_members",
);
expect(calls.slice(0, addIndex)).toEqual(
expect.arrayContaining([
expect.objectContaining({
command: "revalidate_relay_agents",
payload: expect.objectContaining({
channelId: FORUM,
pubkeys: [REMOTE],
}),
}),
]),
);
const afterAdd = calls.slice(addIndex + 1);
const refreshIndex = afterAdd.findIndex(
(call) => call.command === "get_channel_members",
);
const finalCheckIndex = afterAdd.findIndex(
(call) => call.command === "revalidate_relay_agents",
);
expect(refreshIndex).toBeGreaterThanOrEqual(0);
expect(finalCheckIndex).toBeGreaterThan(refreshIndex);
await assertNoLocalLifecycle(page);
});
test("standalone forum cancelled Invite keeps selected identity and draft without publishing", async ({
page,
}) => {
const dialog = await openStandaloneForumInvite(page);
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByTestId("message-input")).toBeFocused();
await expect(page.getByTestId("message-input")).toHaveText(
"@RemoteScout hello",
);
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"true",
);
expect(await sent(page)).toEqual([]);
expect(await forumAdds(page)).toEqual([]);
// Retry without selecting again must preserve the exact intended recipient.
await page.getByTestId("send-message").click();
await page
.getByRole("alertdialog")
.getByRole("button", { name: "Invite", exact: true })
.click();
await expect.poll(() => sent(page)).toEqual([[REMOTE]]);
});
for (const error of [
"actor not authorized",
"policy:nobody — this agent has disabled external channel additions",
"relay unavailable during add",
]) {
test(`standalone forum denied/failed add preserves draft and publishes nothing: ${error}`, async ({
page,
}) => {
const dialog = await openStandaloneForumInvite(page);
await page.evaluate((error) => {
window.__BUZZ_E2E__.mock ??= {};
window.__BUZZ_E2E__.mock.addChannelMembersErrors = [error];
}, error);
await dialog.getByRole("button", { name: "Invite", exact: true }).click();
await expect(dialog.getByRole("alert")).toHaveText(error);
await waitForAnimations(page);
await page.screenshot({
path: `test-results/forum-error-${error.split(" ")[0].replace(/[^a-zA-Z0-9_-]/g, "-")}.png`,
});
await expect(page.getByTestId("message-input")).toHaveText(
"@RemoteScout hello",
);
expect(await sent(page)).toEqual([]);
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"true",
);
expect(await sent(page)).toEqual([]);
await assertNoLocalLifecycle(page);
});
}
test("standalone forum selected identity revoked before Invite fails visibly without adding", async ({
page,
}) => {
const dialog = await openStandaloneForumInvite(page);
await page.evaluate((pubkey) => {
window.__BUZZ_E2E__.mock ??= {};
window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey];
}, REMOTE);
await dialog.getByRole("button", { name: "Invite", exact: true }).click();
await expect(
dialog.getByText(/Could not authorize a mentioned agent/),
).toBeVisible();
await expect(page.getByTestId("message-input")).toHaveText(
"@RemoteScout hello",
);
expect(await forumAdds(page)).toEqual([]);
expect(await sent(page)).toEqual([]);
});
test("standalone forum still requires final authorization after a successful add", async ({
page,
}) => {
const dialog = await openStandaloneForumInvite(page);
await page.evaluate(() => {
window.__BUZZ_E2E__.mock ??= {};
window.__BUZZ_E2E__.mock.addChannelMembersDelayMs = 1_000;
});
await dialog.getByRole("button", { name: "Invite", exact: true }).click();
await expect.poll(async () => (await forumAdds(page)).length).toBe(1);
// Preparation already passed; revoke during the add, before final publish.
await page.evaluate((pubkey) => {
window.__BUZZ_E2E__.mock ??= {};
window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey];
}, REMOTE);
await expect(dialog).toHaveCount(0);
await expect(
page.getByText(/Could not authorize a mentioned agent/),
).toBeVisible();
await expect(page.getByTestId("message-input")).toHaveText(
"@RemoteScout hello",
);
expect(await sent(page)).toEqual([]);
});
test("navigation during an outstanding forum Invite never publishes to either channel", async ({
page,
}) => {
const dialog = await openStandaloneForumInvite(page);
await page.evaluate(() => {
window.__BUZZ_E2E__.mock ??= {};
window.__BUZZ_E2E__.mock.addChannelMembersDelayMs = 800;
});
await dialog.getByRole("button", { name: "Invite", exact: true }).click();
await expect.poll(async () => (await forumAdds(page)).length).toBe(1);
// Route navigation unmounts the composer even while its modal traps focus.
await page.evaluate(() => {
window.location.hash = "/channels/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
});
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.waitForTimeout(1000);
expect(await sent(page)).toEqual([]);
});
// Hold the actual IPC boundary rather than rely on wall-clock sleeps.
type ForumGateWindow = Window & {
__TAURI_INTERNALS__: {
invoke: (command: string, payload?: unknown) => Promise<unknown>;
};
forumGateEntered?: boolean;
forumGateDone?: boolean;
releaseForumGate?: () => void;
};
async function holdForumCommand(
page: Page,
heldCommand: string,
skip = 0,
failure?: string,
) {
await page.evaluate(
({ heldCommand, skip, failure }) => {
const state = window as unknown as ForumGateWindow;
const invoke = state.__TAURI_INTERNALS__.invoke;
state.forumGateEntered = false;
state.forumGateDone = false;
const gate = new Promise<void>((resolve) => {
state.releaseForumGate = resolve;
});
state.__TAURI_INTERNALS__.invoke = async (command, payload) => {
if (command !== heldCommand || skip-- > 0)
return invoke(command, payload);
state.__TAURI_INTERNALS__.invoke = invoke;
state.forumGateEntered = true;
await gate;
try {
if (failure) throw new Error(failure);
return await invoke(command, payload);
} finally {
state.forumGateDone = true;
}
};
},
{ heldCommand, skip, failure },
);
}
async function waitForForumGate(page: Page) {
await expect
.poll(() =>
page.evaluate(
() => (window as unknown as ForumGateWindow).forumGateEntered,
),
)
.toBe(true);
}
async function releaseForumGate(page: Page) {
await page.evaluate(() =>
(window as unknown as ForumGateWindow).releaseForumGate?.(),
);
await expect
.poll(() =>
page.evaluate(() => (window as unknown as ForumGateWindow).forumGateDone),
)
.toBe(true);
}
for (const stage of ["add", "publish"] as const) {
for (const replacement of [null, "new A draft", ""] as const) {
test(`forum source visit ${stage}: A→B→A retains source draft; replacement=${replacement}`, async ({
page,
}) => {
await install(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: "source.pdf",
},
],
});
await page.getByTestId("channel-watercooler").click();
await expect
.poll(() =>
page.evaluate(() =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: "watercooler",
}),
),
)
.toBe(true);
const roots = await page.evaluate(() =>
["Source forum A", "Source forum B"].map(
(content) =>
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "watercooler",
content,
kind: 45001,
})?.id,
),
);
expect(roots.every(Boolean)).toBe(true);
const navigate = async (index: number) => {
await page.evaluate(
({ forum, id }) => {
window.location.hash = `/channels/${forum}/posts/${id}`;
},
{ forum: FORUM, id: roots[index] },
);
await expect(
page.getByText(index === 0 ? "Source forum A" : "Source forum B", {
exact: true,
}),
).toBeVisible();
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"true",
);
};
// Cache both threads before the critical transition, so query loading
// cannot accidentally supply the missing source-visit boundary.
await navigate(1);
await page.getByTestId("message-input").fill("B owns this draft");
await navigate(0);
await select(page);
if (replacement === "new A draft") {
await page.getByRole("button", { name: "Attach file" }).click();
await expect(
page.getByRole("button", { name: "Remove attachment" }),
).toBeVisible();
}
await page.getByTestId("send-message").click();
await holdForumCommand(
page,
stage === "add" ? "add_channel_members" : "revalidate_relay_agents",
stage === "publish" ? 1 : 0,
);
await page.getByRole("button", { name: "Invite", exact: true }).click();
await waitForForumGate(page);
await navigate(1);
await expect(page.getByRole("alertdialog")).toHaveCount(0);
await expect(page.getByTestId("message-input")).toHaveText(
"B owns this draft",
);
await navigate(0);
await expect(page.getByTestId("message-input")).toHaveText(
"@RemoteScout hello",
);
if (replacement === "new A draft")
await expect(
page.getByRole("button", { name: "Remove attachment" }),
).toBeVisible();
if (replacement !== null)
await page.getByTestId("message-input").fill(replacement);
await navigate(1);
await releaseForumGate(page);
expect(
await page.evaluate(() =>
(window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []).filter(
(event) => event.kind === 45001 || event.kind === 45003,
),
),
).toEqual([]);
expect(await sent(page)).toEqual([]);
await expect(page.getByTestId("message-input")).toHaveText(
"B owns this draft",
);
await navigate(0);
await expect(page.getByTestId("message-input")).toHaveText(
replacement ?? "@RemoteScout hello",
);
if (replacement === null) {
// No re-selection: a recovered selected identity must still route exactly.
await page.getByTestId("send-message").click();
const dialog = page.getByRole("alertdialog");
if (await dialog.count())
await dialog
.getByRole("button", { name: "Invite", exact: true })
.click();
await expect.poll(() => sent(page)).toEqual([[REMOTE]]);
}
await assertNoLocalLifecycle(page);
});
}
}
test("forum Escape cancels a pending add, restores focus, and a late add cannot publish", async ({
page,
}) => {
const dialog = await openStandaloneForumInvite(page);
await holdForumCommand(page, "add_channel_members");
await dialog.getByRole("button", { name: "Invite", exact: true }).click();
await waitForForumGate(page);
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(page.getByTestId("message-input")).toBeFocused();
await expect(page.getByTestId("message-input")).toHaveText(
"@RemoteScout hello",
);
await releaseForumGate(page);
expect(await sent(page)).toEqual([]);
});
test("shared chat dialog keeps reference-only action and restores composer focus after Escape", async ({
page,
}) => {
await install(page);
await select(page);
await page.getByTestId("send-message").click();
const dialog = page.getByRole("alertdialog");
await expect(
dialog.getByRole("button", { name: "Do nothing", exact: true }),
).toBeFocused();
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(page.getByTestId("message-input")).toBeFocused();
await expect(page.getByTestId("message-input")).toHaveText(
"@RemoteScout hello",
);
await page.getByTestId("send-message").click();
await dialog.getByRole("button", { name: "Do nothing", exact: true }).click();
await expect.poll(() => sent(page)).toEqual([[]]);
expect(await forumAdds(page)).toEqual([]);
});
for (const replacement of [null, "new A draft", ""] as const) {
test(`forum failed dispatched reply A→B→A recovers only without newer intent: ${replacement}`, async ({
page,
}) => {
await install(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: "source.pdf",
},
],
});
await page.getByTestId("channel-watercooler").click();
await expect
.poll(() =>
page.evaluate(() =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: "watercooler",
}),
),
)
.toBe(true);
const roots = await page.evaluate(() =>
["Transport A", "Transport B"].map(
(content) =>
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "watercooler",
content,
kind: 45001,
})?.id,
),
);
expect(roots.every(Boolean)).toBe(true);
const navigate = async (index: number) => {
await page.evaluate(
({ forum, id }) => {
window.location.hash = `/channels/${forum}/posts/${id}`;
},
{ forum: FORUM, id: roots[index] },
);
await expect(
page.getByText(index === 0 ? "Transport A" : "Transport B", {
exact: true,
}),
).toBeVisible();
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"true",
);
};
await navigate(1);
await page.getByTestId("message-input").fill("B owns this draft");
await navigate(0);
await select(page);
await page.getByRole("button", { name: "Attach file" }).click();
await expect(
page.getByRole("button", { name: "Remove attachment" }),
).toBeVisible();
await holdForumCommand(
page,
"send_channel_message",
0,
"transport offline",
);
await page.getByTestId("send-message").click();
await page.getByRole("button", { name: "Invite", exact: true }).click();
await waitForForumGate(page);
await navigate(1);
await expect(page.getByTestId("message-input")).toHaveText(
"B owns this draft",
);
await navigate(0);
await expect(page.getByTestId("message-input")).toHaveText("");
if (replacement !== null) {
// An actual edit -> delete is authoritative, not an unchanged empty editor.
await page.getByTestId("message-input").fill("replacement intent");
if (replacement === "") {
await page.getByTestId("message-input").press("ControlOrMeta+a");
await page.getByTestId("message-input").press("Backspace");
} else {
await page.getByTestId("message-input").fill(replacement);
}
await expect(page.getByTestId("message-input")).toHaveText(replacement);
}
await releaseForumGate(page);
await expect(page.getByTestId("message-input")).toHaveText(
replacement ?? "@RemoteScout hello",
);
await expect(
page.getByRole("button", { name: "Remove attachment" }),
).toHaveCount(replacement === null ? 1 : 0);
await navigate(1);
await expect(page.getByTestId("message-input")).toHaveText(
"B owns this draft",
);
await navigate(0);
await expect(page.getByTestId("message-input")).toHaveText(
replacement ?? "@RemoteScout hello",
);
// Recovery only restores authorship: no automatic send, add, or signing replay.
expect(await forumAdds(page)).toHaveLength(1);
const replies = () =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
(call) =>
call.command === "send_channel_message" &&
(call.payload as { kind?: number })?.kind === 45003,
),
);
expect(await replies()).toHaveLength(0);
if (replacement === null) {
await page.getByTestId("send-message").click();
await expect.poll(replies).toHaveLength(1);
expect((await replies())[0].payload).toEqual(
expect.objectContaining({
channelId: FORUM,
mentionPubkeys: [REMOTE],
content: expect.stringContaining("@RemoteScout hello"),
mediaTags: expect.arrayContaining([
expect.arrayContaining([
"imeta",
`url https://mock.relay/media/${"f".repeat(64)}.pdf`,
]),
]),
}),
);
}
await assertNoLocalLifecycle(page);
});
}