mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## Summary
The `replacement=` cases (`add` and `publish`) of `forum source visit …:
A→B→A retains source draft` in `forum-agent-invitation.spec.ts` are
flaky. They fail at line 449 because the source draft still reads
`@RemoteScout hello`.
Cause: Playwright's `fill("")` on a contenteditable sets a DOM selection
and then presses `Delete`. ProseMirror handles `Delete` with its own
selection, which sometimes hasn't synced from the DOM yet. When that
happens the key press does nothing, `onUpdate` never fires, and the
clear is never recorded. I confirmed this with temporary logging:
failing runs have no authored-content update after the fill. Non-empty
`fill` uses `insertText`, which goes through ProseMirror's DOM input
path, so it isn't affected.
Fix (test only):
- Clear the draft with `ControlOrMeta+a` followed by `Delete`, so
ProseMirror owns the selection.
- Assert the replacement text before navigating away.
### Related issue
None found. The spec hasn't changed since #7125. This test failed in 10
of the last 40 CI runs of `Desktop Smoke E2E (2)`, for example #3700.
### Testing
- `--repeat-each=100 --retries=0 --workers=4` on the two affected cases:
- Before the fix: 14 of 200 runs failed at `:449`.
- After the fix: 200 of 200 passed.
- Full `forum-agent-invitation.spec.ts`: 19 passed.
- Pre-push hooks passed (desktop typecheck and desktop tests).
Signed-off-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
650 lines
22 KiB
TypeScript
650 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 === "") {
|
|
// fill("") presses Delete over a DOM selection ProseMirror may not
|
|
// have synced yet; select through the editor's own keymap instead.
|
|
await page.getByTestId("message-input").click();
|
|
await page.keyboard.press("ControlOrMeta+a");
|
|
await page.keyboard.press("Delete");
|
|
} else if (replacement !== null) {
|
|
await page.getByTestId("message-input").fill(replacement);
|
|
}
|
|
if (replacement !== null)
|
|
await expect(page.getByTestId("message-input")).toHaveText(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);
|
|
});
|
|
}
|