mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
fix(desktop): scope Bestie handoffs to their workspace
Co-authored-by: Codex <noreply@openai.com> Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
@@ -453,6 +453,8 @@ export function useSendMessageMutation(
|
||||
sentFromThreadRootId?: string | null;
|
||||
sentFromThreadRootExcerpt?: string | null;
|
||||
transport?: "auto" | "http";
|
||||
expectedRelayUrl?: string;
|
||||
expectedSignerPubkey?: string;
|
||||
},
|
||||
MessageQueryContext | undefined
|
||||
>({
|
||||
@@ -467,6 +469,8 @@ export function useSendMessageMutation(
|
||||
sentFromThreadRootId,
|
||||
sentFromThreadRootExcerpt,
|
||||
transport = "auto",
|
||||
expectedRelayUrl,
|
||||
expectedSignerPubkey,
|
||||
}) => {
|
||||
// Prefer a channel captured by the caller at compose time. Otherwise,
|
||||
// resolve a captured id from the shared channel cache so navigation
|
||||
@@ -493,6 +497,11 @@ export function useSendMessageMutation(
|
||||
if (!identity) {
|
||||
throw new Error("No identity available for sending messages.");
|
||||
}
|
||||
if (Boolean(expectedRelayUrl) !== Boolean(expectedSignerPubkey)) {
|
||||
throw new Error(
|
||||
"Scoped message sends require both a relay and signer identity.",
|
||||
);
|
||||
}
|
||||
|
||||
// `mediaTags` arrives as the merged outgoing tag set (imeta + NIP-30
|
||||
// emoji). Split it so each kind goes to its own validated Tauri arg —
|
||||
@@ -531,7 +540,8 @@ export function useSendMessageMutation(
|
||||
parentEventId ||
|
||||
imetaTags.length > 0 ||
|
||||
emojiTags.length > 0 ||
|
||||
linkPreviewTags.length > 0
|
||||
linkPreviewTags.length > 0 ||
|
||||
expectedRelayUrl
|
||||
) {
|
||||
const cachedMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>(
|
||||
@@ -559,8 +569,8 @@ export function useSendMessageMutation(
|
||||
mentionTags,
|
||||
linkPreviewTags,
|
||||
sentFromThreadTag,
|
||||
undefined,
|
||||
undefined,
|
||||
expectedRelayUrl,
|
||||
expectedSignerPubkey,
|
||||
suppliedRootEventId,
|
||||
);
|
||||
|
||||
|
||||
@@ -55,10 +55,13 @@ import {
|
||||
getActiveDraftEntries,
|
||||
getAllDraftEntries,
|
||||
getSentDraftEntries,
|
||||
getDraftStoreScope,
|
||||
initDraftStore,
|
||||
loadDraftEntry,
|
||||
loadDraftEntryForScope,
|
||||
markDraftSentEntry,
|
||||
persistDraftEntry,
|
||||
persistDraftEntryForScope,
|
||||
renameDraftEntry,
|
||||
saveDraftEntry,
|
||||
subscribeToStore,
|
||||
@@ -1045,6 +1048,35 @@ test("cross_relay_isolation_via_initDraftStore_without_clearAllDrafts", () => {
|
||||
assert.notEqual(v2a, v2b, "buckets must differ");
|
||||
});
|
||||
|
||||
test("captured scope recovers an in-flight draft after the active relay changes", () => {
|
||||
installFreshLocalStorage();
|
||||
clearAllDrafts();
|
||||
const pk = "pubkey-in-flight";
|
||||
initDraftStore(pk, "wss://relay-a.example.com");
|
||||
const capturedScope = getDraftStoreScope();
|
||||
|
||||
initDraftStore(pk, "wss://relay-b.example.com");
|
||||
persistDraftEntryForScope(
|
||||
capturedScope,
|
||||
"bestie-share:event",
|
||||
"Keep this in relay A",
|
||||
"bestie-share:event",
|
||||
[],
|
||||
[],
|
||||
);
|
||||
|
||||
assert.equal(loadDraftEntry("bestie-share:event"), undefined);
|
||||
assert.equal(
|
||||
loadDraftEntryForScope(capturedScope, "bestie-share:event")?.content,
|
||||
"Keep this in relay A",
|
||||
);
|
||||
initDraftStore(pk, "wss://relay-a.example.com");
|
||||
assert.equal(
|
||||
loadDraftEntry("bestie-share:event")?.content,
|
||||
"Keep this in relay A",
|
||||
);
|
||||
});
|
||||
|
||||
test("legacy_migration_v1_entries_readable_after_scoped_init_v1_key_removed", () => {
|
||||
const storage = installFreshLocalStorage();
|
||||
clearAllDrafts();
|
||||
|
||||
@@ -118,6 +118,39 @@ export function getDraftStoreScope(): string {
|
||||
return storageKey();
|
||||
}
|
||||
|
||||
function readExplicitStore(scope: string): Map<string, DraftState> {
|
||||
if (scope === storageKey()) return readStore();
|
||||
const map = new Map<string, DraftState>();
|
||||
const raw = localStorage.getItem(scope);
|
||||
if (!raw) return map;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
parsed !== null &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed)
|
||||
) {
|
||||
for (const [key, value] of Object.entries(parsed as StoredDrafts)) {
|
||||
if (!key.startsWith("sent:") && isValidDraftState(value)) {
|
||||
map.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Match readStore's fail-open corruption behavior without mutating an
|
||||
// inactive workspace while its owner is away.
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function flushExplicitStore(
|
||||
scope: string,
|
||||
map: Map<string, DraftState>,
|
||||
): boolean {
|
||||
const value = Object.fromEntries(map);
|
||||
return setLocalStorageItemWithRecovery(scope, JSON.stringify(value));
|
||||
}
|
||||
|
||||
function legacyStorageKey(): string {
|
||||
return `${LEGACY_DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`;
|
||||
}
|
||||
@@ -301,6 +334,14 @@ export function loadDraftEntry(draftKey: string): DraftState | undefined {
|
||||
return readStore().get(draftKey);
|
||||
}
|
||||
|
||||
/** Read a draft from the workspace scope captured before an async send. */
|
||||
export function loadDraftEntryForScope(
|
||||
scope: string,
|
||||
draftKey: string,
|
||||
): DraftState | undefined {
|
||||
return readExplicitStore(scope).get(draftKey);
|
||||
}
|
||||
|
||||
export function deleteDraftEntry(draftKey: string): void {
|
||||
discardQueuedAttachmentsForDraft(draftKey);
|
||||
clearDraftEntry(draftKey);
|
||||
@@ -461,6 +502,55 @@ export function persistDraftEntry(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover an async send into the exact workspace that owned it, even when the
|
||||
* active relay/identity changed while publication was in flight.
|
||||
*/
|
||||
export function persistDraftEntryForScope(
|
||||
scope: string,
|
||||
draftKey: string,
|
||||
content: string,
|
||||
channelId: string,
|
||||
pendingImeta: ImetaMedia[],
|
||||
spoileredAttachmentUrls: string[],
|
||||
mentionRefs: DraftMentionRef[] = [],
|
||||
): void {
|
||||
if (scope === storageKey()) {
|
||||
persistDraftEntry(
|
||||
draftKey,
|
||||
content,
|
||||
channelId,
|
||||
pendingImeta,
|
||||
spoileredAttachmentUrls,
|
||||
mentionRefs,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const map = readExplicitStore(scope);
|
||||
const hasContent = content.trim().length > 0 || pendingImeta.length > 0;
|
||||
if (!hasContent) {
|
||||
map.delete(draftKey);
|
||||
flushExplicitStore(scope, map);
|
||||
return;
|
||||
}
|
||||
const existing = map.get(draftKey);
|
||||
const now = new Date().toISOString();
|
||||
map.set(draftKey, {
|
||||
content,
|
||||
selectionEnd: content.length,
|
||||
selectionStart: content.length,
|
||||
channelId,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
pendingImeta,
|
||||
mentionRefs,
|
||||
spoileredAttachmentUrls,
|
||||
status: "active",
|
||||
});
|
||||
evictOldest(map);
|
||||
flushExplicitStore(scope, map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all drafts sorted most-recently-updated first.
|
||||
* Used by the Drafts inbox panel (Phase 2).
|
||||
|
||||
@@ -50,6 +50,19 @@ export function BestieMessagePanel({
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
) => {
|
||||
// The action can outlive its rendered community while opening the DM and
|
||||
// preparing optimistic state. Capture both ownership dimensions before
|
||||
// the first await and require the backend to reject publication if either
|
||||
// one changes in flight.
|
||||
const expectedRelayUrl = activeCommunity?.relayUrl;
|
||||
const expectedSignerPubkey = identityQuery.data?.pubkey;
|
||||
if (!expectedRelayUrl || !expectedSignerPubkey) {
|
||||
const error = new Error(
|
||||
"Bestie messages require an active community and signing identity.",
|
||||
);
|
||||
toast.error(`Couldn't send to ${bestie.name}`);
|
||||
throw error;
|
||||
}
|
||||
const { rootId } = getThreadReference(message.tags ?? []);
|
||||
const link = buildMessageLink({
|
||||
channelId,
|
||||
@@ -64,14 +77,17 @@ export function BestieMessagePanel({
|
||||
try {
|
||||
const dm = await openDmMutation.mutateAsync({
|
||||
pubkeys: [bestie.pubkey],
|
||||
expectedRelayUrl: activeCommunity?.relayUrl,
|
||||
expectedSignerPubkey: identityQuery.data?.pubkey,
|
||||
expectedRelayUrl,
|
||||
expectedSignerPubkey,
|
||||
});
|
||||
await sendMessageMutation.mutateAsync({
|
||||
content,
|
||||
expectedRelayUrl,
|
||||
expectedSignerPubkey,
|
||||
mediaTags,
|
||||
mentionPubkeys,
|
||||
targetChannel: dm,
|
||||
transport: "http",
|
||||
});
|
||||
onClose();
|
||||
toast.success(`Sent to ${bestie.name}`);
|
||||
|
||||
@@ -232,12 +232,16 @@ import { act } from "react";
|
||||
|
||||
// Production hook under test — owns the restore effect, cleanup, and the
|
||||
// synchronous ref write that is the StrictMode fix.
|
||||
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot.ts";
|
||||
import {
|
||||
markRecoveredDraftRestorable,
|
||||
useDraftPersistLifecycle,
|
||||
} from "./useDraftPersistSnapshot.ts";
|
||||
|
||||
// Real storage functions — the test uses them, not a replica.
|
||||
import {
|
||||
clearAllDrafts,
|
||||
deleteDraftEntry,
|
||||
getDraftStoreScope,
|
||||
initDraftStore,
|
||||
loadDraftEntry,
|
||||
persistDraftEntry,
|
||||
@@ -639,6 +643,53 @@ test("draft_lifecycle_persists_an_explicit_clear_before_async_rerender", async (
|
||||
await remounted.unmount();
|
||||
});
|
||||
|
||||
test("draft_lifecycle_restores_an_async_send_recovery_after_optimistic_clear", async () => {
|
||||
const DRAFT_KEY = "chan-send-recovery";
|
||||
const ORIGINAL = "recover this failed send";
|
||||
setupStore("pubkey-send-recovery");
|
||||
persistDraftEntry(DRAFT_KEY, ORIGINAL, DRAFT_KEY, [], []);
|
||||
|
||||
let editorContent = "";
|
||||
let trackAuthoredContent;
|
||||
const spoileredRef = { current: new Set() };
|
||||
|
||||
function HarnessComposer() {
|
||||
({ trackAuthoredContent } = useDraftPersistLifecycle({
|
||||
effectiveDraftKey: DRAFT_KEY,
|
||||
channelId: DRAFT_KEY,
|
||||
loadDraft: loadDraftEntry,
|
||||
persistDraft: persistDraftEntry,
|
||||
getMentionRefs: () => [],
|
||||
restoreMentionRefs: () => {},
|
||||
livePendingImeta: [],
|
||||
setPendingImeta: () => {},
|
||||
setContent: (content) => {
|
||||
editorContent = content;
|
||||
},
|
||||
clearContent: () => {
|
||||
editorContent = "";
|
||||
},
|
||||
setSpoileredAttachmentUrls: () => {},
|
||||
spoileredAttachmentUrlsRef: spoileredRef,
|
||||
syncComposerContentFromEditor: () => editorContent,
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
|
||||
const handle = await mountStrictMode(HarnessComposer);
|
||||
assert.equal(editorContent, ORIGINAL);
|
||||
trackAuthoredContent("");
|
||||
editorContent = "";
|
||||
await handle.unmount();
|
||||
|
||||
persistDraftEntry(DRAFT_KEY, ORIGINAL, DRAFT_KEY, [], []);
|
||||
markRecoveredDraftRestorable(getDraftStoreScope(), DRAFT_KEY);
|
||||
|
||||
const remounted = await mountStrictMode(HarnessComposer);
|
||||
assert.equal(editorContent, ORIGINAL);
|
||||
await remounted.unmount();
|
||||
});
|
||||
|
||||
test("draft_lifecycle_clear_caption_preserves_image_and_spoiler_on_remount", async () => {
|
||||
const DRAFT_KEY = "chan-clear-caption-image";
|
||||
setupStore("pubkey-clear-caption-image");
|
||||
|
||||
@@ -76,6 +76,17 @@ function scopedDraftKey(draftKey: string): string {
|
||||
return `${getDraftStoreScope()}:${draftKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an asynchronously recovered draft visible again after the optimistic
|
||||
* send path marked its previous empty editor state as authoritative.
|
||||
*/
|
||||
export function markRecoveredDraftRestorable(
|
||||
scope: string,
|
||||
draftKey: string,
|
||||
): void {
|
||||
authoritativelyClearedDraftKeys.delete(`${scope}:${draftKey}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the draft-persist lifecycle for `MessageComposer`.
|
||||
*
|
||||
|
||||
@@ -32,6 +32,7 @@ export type PendingNonMemberMentionSend = {
|
||||
savedSpoileredAttachmentUrls: Set<string>;
|
||||
sentDraftKey: string | null | undefined;
|
||||
recoveryDraftKey: string | null | undefined;
|
||||
recoveryDraftStoreScope: string;
|
||||
savedMentionRefs: DraftMentionRef[];
|
||||
};
|
||||
|
||||
|
||||
@@ -24,7 +24,13 @@ import {
|
||||
buildOutgoingMessage,
|
||||
type ImetaMedia,
|
||||
} from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import {
|
||||
getDraftStoreScope,
|
||||
loadDraftEntryForScope,
|
||||
persistDraftEntryForScope,
|
||||
} from "@/features/messages/lib/useDrafts";
|
||||
import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews";
|
||||
import { markRecoveredDraftRestorable } from "./useDraftPersistSnapshot";
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
import type { AcpRuntime, ManagedAgent } from "@/shared/api/types";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
@@ -335,8 +341,9 @@ export function useMentionSendFlow({
|
||||
? prepareBackgroundMediaUpload(draft.queuedAttachments)
|
||||
: null;
|
||||
const persistPreflightDraft = () => {
|
||||
if (isSendCancelled() || !draft.recoveryDraftKey) return;
|
||||
drafts.persistDraft(
|
||||
if (!draft.recoveryDraftKey) return;
|
||||
persistDraftEntryForScope(
|
||||
draft.recoveryDraftStoreScope,
|
||||
draft.recoveryDraftKey,
|
||||
draft.savedContent,
|
||||
draft.capturedChannelId ?? draft.recoveryDraftKey,
|
||||
@@ -344,14 +351,21 @@ export function useMentionSendFlow({
|
||||
[...draft.savedSpoileredAttachmentUrls],
|
||||
draft.savedMentionRefs,
|
||||
);
|
||||
markRecoveredDraftRestorable(
|
||||
draft.recoveryDraftStoreScope,
|
||||
draft.recoveryDraftKey,
|
||||
);
|
||||
saveQueuedAttachmentsForDraft(
|
||||
draft.recoveryDraftKey,
|
||||
draft.queuedAttachments,
|
||||
);
|
||||
};
|
||||
const persistCanceledDraft = () => {
|
||||
if (isSendCancelled() || !draft.recoveryDraftKey) return;
|
||||
const existing = drafts.loadDraft(draft.recoveryDraftKey);
|
||||
if (!draft.recoveryDraftKey) return;
|
||||
const existing = loadDraftEntryForScope(
|
||||
draft.recoveryDraftStoreScope,
|
||||
draft.recoveryDraftKey,
|
||||
);
|
||||
if (
|
||||
existing &&
|
||||
(existing.content !== draft.savedContent ||
|
||||
@@ -364,7 +378,8 @@ export function useMentionSendFlow({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
drafts.persistDraft(
|
||||
persistDraftEntryForScope(
|
||||
draft.recoveryDraftStoreScope,
|
||||
draft.recoveryDraftKey,
|
||||
draft.savedContent,
|
||||
draft.capturedChannelId ?? draft.recoveryDraftKey,
|
||||
@@ -372,6 +387,10 @@ export function useMentionSendFlow({
|
||||
[...draft.savedSpoileredAttachmentUrls],
|
||||
draft.savedMentionRefs,
|
||||
);
|
||||
markRecoveredDraftRestorable(
|
||||
draft.recoveryDraftStoreScope,
|
||||
draft.recoveryDraftKey,
|
||||
);
|
||||
};
|
||||
let composerCleared = false;
|
||||
let optimisticComposerContent = "";
|
||||
@@ -681,6 +700,7 @@ export function useMentionSendFlow({
|
||||
if (isMentionSendPendingRef.current) {
|
||||
return;
|
||||
}
|
||||
const recoveryDraftStoreScope = getDraftStoreScope();
|
||||
isMentionSendPendingRef.current = true;
|
||||
setIsMentionSendPending(true);
|
||||
const isSendCancelled = () =>
|
||||
@@ -798,6 +818,7 @@ export function useMentionSendFlow({
|
||||
savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls),
|
||||
sentDraftKey,
|
||||
recoveryDraftKey,
|
||||
recoveryDraftStoreScope,
|
||||
savedMentionRefs,
|
||||
};
|
||||
if (promptNonMemberPubkeys.length > 0) {
|
||||
|
||||
@@ -28,6 +28,10 @@ const bestie = {
|
||||
relayUrl: RELAY_A,
|
||||
status: "running" as const,
|
||||
};
|
||||
const bestieInCommunityB = {
|
||||
...bestie,
|
||||
relayUrl: COMMUNITY_B.relayUrl,
|
||||
};
|
||||
|
||||
async function seedCommunities(
|
||||
page: import("@playwright/test").Page,
|
||||
@@ -444,3 +448,115 @@ test("a message can be handed to Bestie with an optional note", async ({
|
||||
);
|
||||
await expect(sentMessage).toContainText("Open original message");
|
||||
});
|
||||
|
||||
test("a Bestie handoff fails closed across a community switch and preserves its draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(
|
||||
page,
|
||||
{
|
||||
managedAgents: [bestie, bestieInCommunityB],
|
||||
sendMessageDelayMs: 1_000,
|
||||
},
|
||||
{ skipCommunitySeed: true },
|
||||
);
|
||||
await seedCommunities(page);
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const messageId = await page.evaluate(() => {
|
||||
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
||||
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
||||
return emit({
|
||||
channelName: "general",
|
||||
content: "Keep this handoff in its original community",
|
||||
id: "e".repeat(64),
|
||||
}).id;
|
||||
});
|
||||
|
||||
const draft = "This note must stay in Alpha.";
|
||||
const row = page.locator(`[data-message-id="${messageId}"]`);
|
||||
await row.hover();
|
||||
await row.getByTestId(`send-to-bestie-${messageId}`).click();
|
||||
const popover = page.getByTestId(`bestie-popover-${messageId}`);
|
||||
const editor = popover.locator('[contenteditable="true"]');
|
||||
await editor.fill(draft);
|
||||
await popover.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
window.__BUZZ_E2E_COMMAND_LOG__?.findLast(
|
||||
(entry) => entry.command === "send_channel_message",
|
||||
)?.payload,
|
||||
),
|
||||
)
|
||||
.toMatchObject({
|
||||
content: expect.stringContaining(draft),
|
||||
expectedRelayUrl: RELAY_A,
|
||||
expectedSignerPubkey: OWNER_PUBKEY,
|
||||
});
|
||||
|
||||
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.localStorage.getItem("buzz-active-community-id"),
|
||||
),
|
||||
)
|
||||
.toBe(COMMUNITY_B.id);
|
||||
await page.waitForTimeout(1_150);
|
||||
|
||||
// The two fixtures intentionally resolve to the same participant-derived
|
||||
// DM id. An unscoped send would therefore appear in both tenant views.
|
||||
await page
|
||||
.getByTestId("sidebar-channel-content")
|
||||
.getByRole("button", { name: /Bestie/ })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("Bestie");
|
||||
await expect(page.getByTestId("message-timeline")).not.toContainText(draft);
|
||||
|
||||
await page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`).click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.localStorage.getItem("buzz-active-community-id"),
|
||||
),
|
||||
)
|
||||
.toBe(COMMUNITY_A.id);
|
||||
await page
|
||||
.getByTestId("sidebar-channel-content")
|
||||
.getByRole("button", { name: /Bestie/ })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("Bestie");
|
||||
await expect(page.getByTestId("message-timeline")).not.toContainText(draft);
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.evaluate((id) => {
|
||||
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
||||
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
||||
emit({
|
||||
channelName: "general",
|
||||
content: "Keep this handoff in its original community",
|
||||
id,
|
||||
});
|
||||
}, messageId);
|
||||
const restoredRow = page.locator(`[data-message-id="${messageId}"]`);
|
||||
await restoredRow.hover();
|
||||
await restoredRow.getByTestId(`send-to-bestie-${messageId}`).click();
|
||||
const restoredPopover = page.getByTestId(`bestie-popover-${messageId}`);
|
||||
await expect(restoredPopover.locator('[contenteditable="true"]')).toHaveText(
|
||||
draft,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
if (window.__BUZZ_E2E__?.mock) {
|
||||
window.__BUZZ_E2E__.mock.sendMessageDelayMs = 0;
|
||||
}
|
||||
});
|
||||
await restoredPopover.getByRole("button", { name: "Send" }).click();
|
||||
await expect(restoredPopover).toBeHidden();
|
||||
await page.getByTestId("open-bestie-dm").click();
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(draft);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user