mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
## 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.  *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>
641 lines
22 KiB
TypeScript
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);
|
|
});
|
|
}
|