Files
buzz/desktop/tests/e2e/onboarding.spec.ts
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

3789 lines
127 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { hexToBytes } from "@noble/hashes/utils.js";
import { expect, test, type Page } from "@playwright/test";
import { nsecEncode } from "nostr-tools/nip19";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { installFakeCamera } from "../helpers/fakeCamera";
import {
E2E_IDENTITY_OVERRIDE_STORAGE_KEY,
seedActiveIdentity,
} from "../helpers/onboarding";
type RelayConnectionState =
| "connected"
| "connecting"
| "disconnected"
| "idle"
| "reconnecting"
| "stalled";
/**
* Drive the mock relay connection state via the E2E bridge seam.
* When targeting a degraded state, waits for baseline "connected" first so
* the mock auth handshake can't race the override back to "connected".
*/
async function setRelayConnectionState(
page: Page,
state: RelayConnectionState,
) {
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
}
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function",
);
await page.evaluate((nextState) => {
const testWindow = window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (
state: RelayConnectionState,
) => void;
};
const setConnectionState =
testWindow.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__;
if (!setConnectionState) {
throw new Error("Mock relay connection state helper is not installed.");
}
// Open-relay onboarding may not start a socket before the test exercises
// connectivity UI. Establish the same connected baseline explicitly so a
// delayed mock handshake cannot overwrite the degraded state.
if (nextState !== "connected") {
setConnectionState("connected");
}
setConnectionState(nextState);
}, state);
}
const HOME_SEEN_STORAGE_KEY_PREFIX = "buzz-home-feed-seen.v1:";
const COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY =
"buzz-community-onboarding-transaction.v1";
const ONE_PIXEL_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
const BLANK_TYLER_IDENTITY = {
...TEST_IDENTITIES.tyler,
username: "",
};
const BLANK_AVATAR_PLACEHOLDER_IDENTITY = {
...TEST_IDENTITIES.tyler,
pubkey: "1".repeat(64),
username: "",
};
const BLANK_AVATAR_EMOJI_IDENTITY = {
...TEST_IDENTITIES.tyler,
pubkey: "2".repeat(64),
username: "",
};
const FIRST_RUN_ALICE = {
...TEST_IDENTITIES.alice,
username: "",
};
async function seedOnboardingCompletion(page: Page, pubkey: string) {
await page.addInitScript(
({ storageKey }) => {
window.localStorage.setItem(storageKey, "true");
},
{
storageKey: `buzz-onboarding-complete.v1:${pubkey}`,
},
);
}
async function seedCommunityProfileStage(page: Page, id: string) {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript(
({ pubkey, transactionId, transactionStorageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
transactionStorageKey,
JSON.stringify({
id: transactionId,
source: "first-community",
stage: "profile",
relayUrl: "wss://default.example.com",
communityName: "Default",
communityId: "e2e-default-community",
addedCommunity: true,
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{
pubkey: BLANK_TYLER_IDENTITY.pubkey,
transactionId: id,
transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
},
);
}
async function uploadCommunityAvatar(page: Page, filename: string) {
await page.getByTestId("community-avatar-open").click();
await page.getByTestId("community-avatar-input").setInputFiles({
buffer: Buffer.from(ONE_PIXEL_PNG_BASE64, "base64"),
mimeType: "image/png",
name: filename,
});
await page.getByTestId("community-avatar-done").click();
}
async function readHomeSeenStorageKeys(page: Page) {
return page.evaluate((prefix) => {
return Object.keys(window.localStorage).filter((key) =>
key.startsWith(prefix),
);
}, HOME_SEEN_STORAGE_KEY_PREFIX);
}
async function expectNoHomeSeenEntries(page: Page) {
await expect.poll(async () => readHomeSeenStorageKeys(page)).toEqual([]);
}
async function selectFirstEmojiFromPicker(page: Page) {
const picker = page.locator("em-emoji-picker");
await expect(picker).toBeVisible();
await expect
.poll(() =>
picker.evaluate((element) =>
Boolean(element.shadowRoot?.querySelector(".scroll button")),
),
)
.toBe(true);
await picker.evaluate((element) => {
const button = element.shadowRoot?.querySelector(".scroll button");
if (!(button instanceof HTMLElement)) {
throw new Error("Emoji picker did not render an emoji button.");
}
button.click();
});
}
async function expectShellHidden(page: Page) {
await expect(page.getByTestId("app-sidebar")).toHaveCount(0);
await expect(page.getByTestId("chat-title")).toHaveCount(0);
}
async function expectHomeView(page: Page) {
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
}
async function expectWiderThanTall(locator: Locator) {
const box = await locator.boundingBox();
if (!box) {
throw new Error("Could not measure welcome intro action card");
}
expect(box.width).toBeGreaterThan(box.height);
}
async function expectIntroActionIconStackedAboveTitle(
action: Locator,
title: string,
) {
const iconBox = await action.locator("svg").first().boundingBox();
const titleBox = await action.getByText(title, { exact: true }).boundingBox();
if (!iconBox || !titleBox) {
throw new Error("Could not measure welcome intro action content");
}
expect(titleBox.y).toBeGreaterThan(iconBox.y + iconBox.height);
}
async function expectWelcomeComposerBannerLayout(page: Page) {
const banner = page.getByTestId("welcome-composer-guide-banner");
const personaMention = page.getByTestId("welcome-composer-persona-mention");
const composer = page.getByTestId("message-composer");
const bannerBox = await banner.boundingBox();
const personaMentionBox = await personaMention.boundingBox();
const composerBox = await composer.boundingBox();
const dockBackdropBox = await page
.getByTestId("composer-dock-backdrop")
.locator("div")
.boundingBox();
const guidanceLayer = page.getByTestId("welcome-composer-guidance-layer");
const guidanceLayerBox = await guidanceLayer.boundingBox();
const guidanceBackdrop = page.getByTestId(
"welcome-composer-guidance-backdrop",
);
const guidanceBackdropBox = await guidanceBackdrop.boundingBox();
if (
!bannerBox ||
!personaMentionBox ||
!composerBox ||
!dockBackdropBox ||
!guidanceLayerBox ||
!guidanceBackdropBox
) {
throw new Error("Could not measure welcome composer banner layout");
}
expect(
await composer.getByTestId("welcome-composer-guide-banner").count(),
).toBe(0);
expect(bannerBox.y).toBeLessThan(composerBox.y);
// Banner is in normal flow above the composer, no overlap.
expect(bannerBox.y + bannerBox.height).toBeLessThanOrEqual(composerBox.y);
// The dock backdrop is absolute inset-y-0 inside composer-dock, which now
// contains the guidance layer + composer in flow, so its top aligns with the
// guidance layer top (not the composer top).
expect(Math.abs(dockBackdropBox.y - guidanceLayerBox.y)).toBeLessThanOrEqual(
1,
);
expect(guidanceBackdropBox.y).toBeLessThanOrEqual(bannerBox.y);
// The guidance backdrop extends bottom-3 (12px) short of the banner's bottom,
// visually connecting up to the composer.
expect(guidanceBackdropBox.y + guidanceBackdropBox.height).toBeLessThan(
composerBox.y,
);
expect(
await page
.getByTestId("channel-composer-overlay")
.getByTestId("welcome-composer-guidance-layer")
.count(),
).toBe(1);
expect(
await page
.getByTestId("composer-dock-backdrop")
.getByTestId("welcome-composer-guidance-layer")
.count(),
).toBe(0);
const radii = await banner.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backdropFilter: styles.backdropFilter,
backgroundColor: styles.backgroundColor,
bottomLeft: styles.borderBottomLeftRadius,
bottomRight: styles.borderBottomRightRadius,
filter: styles.filter,
zIndex: styles.zIndex,
topLeft: styles.borderTopLeftRadius,
topRight: styles.borderTopRightRadius,
transform: styles.transform,
willChange: styles.willChange,
};
});
const composerBackgroundColor = await composer.evaluate(
(element) => window.getComputedStyle(element).backgroundColor,
);
const dockBackdropFilter = await page
.getByTestId("composer-dock-backdrop")
.locator("div")
.evaluate((element) => window.getComputedStyle(element).backdropFilter);
const guidanceBackdropFilter = await guidanceBackdrop.evaluate(
(element) => window.getComputedStyle(element).backdropFilter,
);
expect(radii.topLeft).toBe(radii.topRight);
expect(radii.bottomLeft).toBe("0px");
expect(radii.bottomRight).toBe("0px");
expect(radii.backdropFilter).toBe("none");
expect(radii.backgroundColor).not.toBe(composerBackgroundColor);
expect(dockBackdropFilter).not.toBe("none");
expect(guidanceBackdropFilter).toBe(dockBackdropFilter);
expect(radii.filter).toBe("none");
expect(radii.transform).toBe("none");
expect(radii.willChange).toBe("auto");
expect(radii.zIndex).toBe("1");
expect(personaMentionBox.width).toBeGreaterThan(0);
}
async function expectWelcomePersonaMention(page: Page) {
const banner = page.getByTestId("welcome-composer-guide-banner");
const personaMention = page.getByTestId("welcome-composer-persona-mention");
await expect(personaMention).toBeVisible();
await expect(personaMention).toHaveAttribute("data-persona-options", "Fizz");
await expect(personaMention).toHaveAttribute("data-active-persona", "Fizz");
await expect(personaMention).toHaveAttribute(
"data-animation-target",
"per-character",
);
const activePersona = await personaMention.getAttribute(
"data-active-persona",
);
expect(activePersona).not.toBeNull();
await expect(personaMention).toContainText(`@${activePersona}`);
expect(
await personaMention
.getByTestId("welcome-composer-persona-character")
.count(),
).toBeGreaterThanOrEqual(4);
expect(
await personaMention
.getByTestId("welcome-composer-persona-character")
.first()
.evaluate((element) => window.getComputedStyle(element).filter),
).toBe("none");
const transition = await personaMention.evaluate((element) => {
const styles = window.getComputedStyle(element);
const durationMs = Number(
element.getAttribute("data-width-animation-duration-ms"),
);
return {
duration: styles.transitionDuration,
durationMs,
property: styles.transitionProperty,
};
});
expect(transition.durationMs).toBeGreaterThanOrEqual(700);
expect(transition.durationMs).toBeLessThanOrEqual(740);
expect(Math.round(Number.parseFloat(transition.duration) * 1000)).toBe(
transition.durationMs,
);
expect(transition.property).toContain("width");
const alignment = await personaMention.evaluate((element) => {
const mentionStyles = window.getComputedStyle(element);
const bannerStyles = window.getComputedStyle(
element.closest('[data-testid="welcome-composer-guide-banner"]') ??
element,
);
return {
display: mentionStyles.display,
lineHeightMatchesBanner:
mentionStyles.lineHeight === bannerStyles.lineHeight,
verticalAlign: mentionStyles.verticalAlign,
};
});
expect(alignment.display).toBe("inline-block");
expect(alignment.verticalAlign).toBe("baseline");
expect(alignment.lineHeightMatchesBanner).toBe(true);
await expect(banner).toContainText("Mention");
}
async function expectPrivateWelcomeLanding(page: Page) {
await expect(page).toHaveURL(/#\/channels\/[^/?#]+$/);
await expect(page.getByTestId("channel-Welcome")).toBeVisible();
await expect(page.getByTestId("chat-title")).toContainText("Welcome");
}
async function expectWelcomeView(page: Page) {
await expectPrivateWelcomeLanding(page);
await expect(page.getByTestId("channel-general")).toBeVisible();
await expect(page.getByTestId("channel-welcome-everyone")).toBeVisible();
await expect(page.getByTestId("channel-ephemeral-Welcome")).toHaveCount(0);
await expect(page.getByTestId("chat-ephemeral-badge")).toHaveCount(0);
await expect(page.getByTestId("message-unread-pill")).toHaveCount(0);
await expect(page.getByTestId("message-channel-intro")).toBeVisible();
await expect(page.getByTestId("message-channel-intro")).toContainText(
"private welcome channel",
);
await expect(
page.getByTestId("message-channel-intro").getByRole("button"),
).toHaveText(["Browse channels", "Create a channel", "Create an agent"]);
await expect(
page.getByTestId("welcome-intro-action-browse-channels"),
).toBeVisible();
await expectWiderThanTall(
page.getByTestId("welcome-intro-action-browse-channels"),
);
await expectIntroActionIconStackedAboveTitle(
page.getByTestId("welcome-intro-action-browse-channels"),
"Browse channels",
);
await page.getByTestId("welcome-intro-action-browse-channels").click();
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await expect(page.getByTestId("channel-browser-search")).toBeFocused();
await expect(page.getByRole("tab", { name: "All channels" })).toHaveAttribute(
"data-state",
"active",
);
await page.keyboard.press("Escape");
await expect(page.getByTestId("channel-browser-dialog")).toHaveCount(0);
await expect(
page.getByTestId("welcome-intro-action-create-channel"),
).toBeVisible();
await expectWiderThanTall(
page.getByTestId("welcome-intro-action-create-channel"),
);
await expectIntroActionIconStackedAboveTitle(
page.getByTestId("welcome-intro-action-create-channel"),
"Create a channel",
);
await expect(
page
.getByTestId("welcome-intro-action-create-channel")
.getByText("Create a channel", { exact: true }),
).toHaveCSS("white-space", "normal");
await expect(
page.getByTestId("welcome-intro-action-create-agent"),
).toBeVisible();
await expectWiderThanTall(
page.getByTestId("welcome-intro-action-create-agent"),
);
await expectIntroActionIconStackedAboveTitle(
page.getByTestId("welcome-intro-action-create-agent"),
"Create an agent",
);
await expect(page.getByTestId("message-composer")).toBeVisible();
await expect(page.getByTestId("welcome-composer-guide-banner")).toBeVisible();
await expect(page.getByTestId("welcome-composer-guide-banner")).toContainText(
"Mention",
);
await expect(page.getByTestId("welcome-composer-guide-banner")).toContainText(
"whenever you want their help.",
);
await expectWelcomePersonaMention(page);
await expectWelcomeComposerBannerLayout(page);
}
async function expectWelcomeComposerBannerCompletesAfterPersonaMention(
page: Page,
) {
const banner = page.getByTestId("welcome-composer-guide-banner");
const channelIntro = page.getByTestId("message-channel-intro");
const composer = page.getByTestId("message-composer");
const initialComposerBox = await composer.boundingBox();
if (!initialComposerBox) {
throw new Error("Could not measure the Welcome composer");
}
await page.getByTestId("message-input").fill("Thanks @Fizz");
await page.getByTestId("send-message").click();
await expect(banner).toHaveAttribute("data-state", "complete");
await expect(banner).toHaveAttribute("data-tone", "success");
await expect(
banner.getByTestId("welcome-composer-complete-icon"),
).toBeVisible();
await expect(
banner.locator('[data-animation-target="success-icon"]'),
).toBeVisible();
await expect(
banner.locator('[data-animation-target="success-copy"]'),
).toBeVisible();
await expect(banner).toContainText("Nice work.");
await expect(banner).not.toContainText("Try mentioning");
await expect(channelIntro).toBeVisible();
const completeComposerBox = await composer.boundingBox();
expect(completeComposerBox).not.toBeNull();
expect(
Math.abs((completeComposerBox?.y ?? 0) - initialComposerBox.y),
).toBeLessThanOrEqual(1);
await expect(banner).toHaveCount(0, { timeout: 6_000 });
const hiddenComposerBox = await composer.boundingBox();
expect(hiddenComposerBox).not.toBeNull();
expect(
Math.abs((hiddenComposerBox?.y ?? 0) - initialComposerBox.y),
).toBeLessThanOrEqual(1);
}
async function getMockChannels(page: Page) {
return page.evaluate(async () => {
const bridgeWindow = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
__TAURI_INTERNALS__?: {
invoke?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
};
const invoke =
bridgeWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ??
bridgeWindow.__TAURI_INTERNALS__?.invoke;
if (!invoke) {
throw new Error("Mock invoke bridge is unavailable.");
}
return (await invoke("get_channels")) as Array<{
id: string;
name: string;
channel_type: string;
visibility: "open" | "private";
member_count: number;
is_member: boolean;
ttl_seconds: number | null;
}>;
});
}
async function invokeMockCommand<T>(
page: Page,
command: string,
payload?: Record<string, unknown>,
) {
return page.evaluate(
async ({ command, payload }) => {
const bridgeWindow = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
__TAURI_INTERNALS__?: {
invoke?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
};
const invoke =
bridgeWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ??
bridgeWindow.__TAURI_INTERNALS__?.invoke;
if (!invoke) {
throw new Error("Mock invoke bridge is unavailable.");
}
return (await invoke(command, payload)) as T;
},
{ command, payload },
);
}
async function seedCurrentAvatar(page: Page, avatarUrl: string) {
await page.waitForFunction(() => {
const bridgeWindow = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
__TAURI_INTERNALS__?: { invoke?: unknown };
};
return (
typeof bridgeWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
typeof bridgeWindow.__TAURI_INTERNALS__?.invoke === "function"
);
});
await invokeMockCommand(page, "update_profile", { avatarUrl });
await page.evaluate(() => {
window.__BUZZ_E2E_COMMAND_PAYLOADS__ = [];
});
}
async function getWelcomeChannelId(page: Page) {
const channels = await getMockChannels(page);
return (
channels.find(
(channel) =>
channel.name === "Welcome" && channel.visibility === "private",
)?.id ?? null
);
}
async function expectStarterChannels(page: Page) {
await expect
.poll(async () => {
const channels = await getMockChannels(page);
return ["general", "welcome-everyone"].map((name) => {
const channel = channels.find(
(candidate) =>
candidate.name === name && candidate.visibility === "open",
);
if (!channel) {
return null;
}
return {
channelType: channel.channel_type,
isMember: channel.is_member,
memberCountAtLeastOne: channel.member_count >= 1,
ttlSeconds: channel.ttl_seconds,
visibility: channel.visibility,
};
});
})
.toEqual([
{
channelType: "stream",
isMember: true,
memberCountAtLeastOne: true,
ttlSeconds: null,
visibility: "open",
},
{
channelType: "stream",
isMember: true,
memberCountAtLeastOne: true,
ttlSeconds: null,
visibility: "open",
},
]);
}
async function expectWelcomeGuideIntro(
page: Page,
{ expectVisible = true }: { expectVisible?: boolean } = {},
) {
await expect
.poll(async () => {
const channelId = await getWelcomeChannelId(page);
if (!channelId) {
return null;
}
const [members, agents] = await Promise.all([
invokeMockCommand<{
members: Array<{ pubkey: string; role: string; is_agent: boolean }>;
}>(page, "get_channel_members", { channelId }),
invokeMockCommand<
Array<{ pubkey: string; name: string; persona_id: string | null }>
>(page, "list_managed_agents"),
]);
const fizz = agents.find(
(agent) => agent.name === "Fizz" && agent.persona_id === "builtin:fizz",
);
const fizzMember = fizz
? members.members.find((member) => member.pubkey === fizz.pubkey)
: null;
const profileAvatarUrl = fizz
? (
await invokeMockCommand<{
profiles: Record<string, { avatar_url: string | null }>;
}>(page, "get_users_batch", {
pubkeys: [fizz.pubkey],
})
).profiles[fizz.pubkey]?.avatar_url
: null;
return {
fizzIsBot: fizzMember?.role === "bot" && fizzMember.is_agent,
fizzPersonaId: fizz?.persona_id ?? null,
profileAvatarUrl,
};
})
.toEqual({
fizzIsBot: true,
fizzPersonaId: "builtin:fizz",
profileAvatarUrl: null,
});
if (expectVisible) {
await expect(page.getByTestId("message-channel-intro")).toBeVisible();
await expect(page.getByTestId("system-message-row")).toHaveCount(0);
}
}
async function expectIncompleteOnboarding(page: Page) {
await expect(page.getByTestId("onboarding-gate")).toBeVisible();
await expectShellHidden(page);
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await expect(page.getByTestId("onboarding-display-name")).toHaveValue("");
}
async function completeProfileOnboarding(page: Page) {
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page
.getByTestId("onboarding-avatar-url")
.fill("https://example.com/onboarding-avatar.png");
await page.getByTestId("onboarding-next").click();
}
test("completed users skip the loading gate while profile is still settling", async ({
page,
}) => {
await seedOnboardingCompletion(page, DEFAULT_MOCK_PUBKEY);
await installMockBridge(page, {
profileReadDelayMs: 3_000,
});
await page.goto("/");
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectHomeView(page);
});
test("fresh existing-identity path leads with private-key recovery", async ({
page,
}) => {
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
await expect(
page.getByText("Paste your private key to sign in to Buzz."),
).toBeVisible();
await expect(page.getByTestId("nostr-import-card")).toBeVisible();
await expect(page.getByTestId("nostr-import-file-button")).toHaveText(
"backup file",
);
await expect(page.getByTestId("nostr-import-phone-link")).toHaveText(
"recover from your phone",
);
await expect(page.getByTestId("identity-recovery-pairing")).toHaveCount(0);
await page.getByTestId("nostr-import-file-button").click();
const backupDialog = page.getByTestId("backup-recovery-dialog");
await expect(backupDialog).toBeVisible();
await expect(
backupDialog.getByRole("heading", { name: "Restore from a backup file" }),
).toBeVisible();
await expect(
backupDialog.getByTestId("nostr-import-backup-picker"),
).toBeVisible();
const unlockPreview = backupDialog.getByTestId("backup-file-unlock-preview");
await expect(unlockPreview).toBeVisible();
await expect(unlockPreview.locator("span")).toHaveCount(17);
await expect(
unlockPreview.getByTestId("backup-file-key-dots").locator("span"),
).toHaveCount(9);
await expect(
unlockPreview.getByTestId("backup-file-unlock-preview-icon"),
).toBeVisible();
await expect(
backupDialog.getByTestId("nostr-import-backup-drop"),
).toHaveCount(0);
await backupDialog
.getByTestId("nostr-import-backup-picker")
.evaluate((element) => {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(
new File(["backup"], "identity.ncryptsec", { type: "text/plain" }),
);
element.dispatchEvent(
new DragEvent("dragenter", {
bubbles: true,
cancelable: true,
dataTransfer,
}),
);
});
const backupDrop = backupDialog.getByTestId("nostr-import-backup-drop");
await expect(backupDrop).toHaveAttribute("data-dragging", "true");
await expect(backupDrop).toContainText("Drop your backup file here");
const [backupDropBox, backupFileSectionBox] = await Promise.all([
backupDrop.boundingBox(),
unlockPreview.boundingBox(),
]);
expect(backupDropBox?.width).toBeGreaterThan(
backupFileSectionBox?.width ?? 0,
);
await expect(
backupDialog.getByTestId("nostr-import-backup-picker"),
).toBeVisible();
await backupDrop.evaluate((element) => {
element.dispatchEvent(
new DragEvent("dragleave", { bubbles: true, cancelable: true }),
);
});
await expect(backupDrop).toHaveCount(0);
await expect(page.getByTestId("nostr-import-card")).toBeVisible();
await backupDialog.getByRole("button", { name: "Close" }).click();
await page.getByTestId("nostr-import-phone-link").click();
const phoneDialog = page.getByTestId("phone-recovery-dialog");
await expect(phoneDialog).toBeVisible();
await expect(
phoneDialog.getByRole("heading", { name: "Use your Buzz identity" }),
).toBeVisible();
await expect(phoneDialog.getByTestId("identity-recovery-qr")).toBeVisible();
await expect(page.getByTestId("nostr-import-card")).toBeVisible();
});
test("first-launch key import continues to machine setup", async ({ page }) => {
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
await expect(page.getByTestId("app-loading-gate")).toHaveCount(0);
});
test("imported-key users can skip out of harness setup", async ({ page }) => {
// Regression: importing an existing key sets the onboarding state machine's
// "continuing" marker, which pinned the stage to onboarding even after
// complete() ran — so Skip/Next silently did nothing. The fresh-key skip
// tests never exercised the import path, so this gap shipped. Prove an
// imported-key user actually leaves onboarding on Skip.
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
await page.getByTestId("onboarding-setup-skip").click();
// Reaching community onboarding proves machine onboarding completed rather
// than staying pinned on the setup step.
await expect(page.getByText("Join or create a community")).toBeVisible();
await expect(page.getByTestId("onboarding-page-2")).toHaveCount(0);
});
test("first-launch encrypted backup import asks for a passphrase and continues", async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
// Spec-vector blob the mock bridge accepts with the mock passphrase.
const mockNcryptsec =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
await page
.getByTestId("nostr-import-nsec-input")
.fill(mockNcryptsec.slice(0, -1));
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
await expect(page.getByTestId("nostr-import-passphrase")).toHaveCount(0);
await page
.getByTestId("nostr-import-nsec-input")
.pressSequentially(mockNcryptsec.slice(-1));
// A complete, checksummed NIP-49 value advances immediately without
// submitting. The password stage updates its copy, illustration, and focus.
await expect(
page.getByRole("heading", { name: "Unlock your account" }),
).toBeVisible();
await expect(page.getByTestId("backup-password-timeline")).toBeVisible();
await expect(page.getByTestId("restore-ncryptsec-affordance")).toBeVisible();
await expect(page.getByTestId("restore-unlock-icon")).toBeVisible();
await expect(page.getByTestId("nostr-import-card")).toHaveCount(0);
await expect(page.getByTestId("nostr-import-file-button")).toHaveCount(0);
await expect(page.getByTestId("nostr-import-passphrase")).toBeFocused();
await expect(page.getByTestId("nostr-import-submit")).toBeDisabled();
// Wrong passphrase surfaces the decrypt error and stays on the form.
await page.getByTestId("nostr-import-passphrase").fill("wrong passphrase");
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("nostr-import-feedback")).toContainText(
/wrong backup password/i,
);
await page
.getByTestId("nostr-import-passphrase")
.fill("mock horse battery staple lake orbit");
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
});
test("first-launch import accepts an .ncryptsec backup file", async ({
page,
}) => {
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
// The spotlight variant must expose a file path: a wiped user returns with
// exactly the identity.ncryptsec our own save dialog produced. The accept
// attribute is asserted explicitly because setInputFiles bypasses it — the
// OS picker is what filters on it in real use.
await page.getByTestId("nostr-import-file-button").click();
const fileInput = page
.getByTestId("backup-recovery-dialog")
.getByTestId("nostr-import-file-input");
await expect(fileInput).toHaveAttribute(
"accept",
".key,.ncryptsec,text/plain",
);
await fileInput.setInputFiles({
buffer: Buffer.alloc(1_025, "x"),
mimeType: "text/plain",
name: "not-a-backup.txt",
});
await expect(
page
.getByTestId("backup-recovery-dialog")
.getByTestId("nostr-import-feedback"),
).toContainText(/too large to be a key backup/i);
// Spec-vector blob the mock bridge accepts with the mock passphrase.
const mockNcryptsec =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
// File contents advance to the password stage inside the same dialog.
const backupDialog = page.getByTestId("backup-recovery-dialog");
const backupFileSection = backupDialog.getByTestId(
"nostr-import-backup-file-section",
);
const backupFileSectionHeight = await backupFileSection.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).height),
);
expect(backupFileSectionHeight).toBe(312);
const backupPicker = backupDialog.getByTestId("nostr-import-backup-picker");
await backupPicker.evaluate((element) => {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(
new File(["backup"], "identity.ncryptsec", { type: "text/plain" }),
);
element.dispatchEvent(
new DragEvent("dragenter", {
bubbles: true,
cancelable: true,
dataTransfer,
}),
);
});
const backupDrop = backupDialog.getByTestId("nostr-import-backup-drop");
await expect(backupDrop).toBeVisible();
await backupDrop.evaluate((element, contents) => {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(
new File([contents], "identity.ncryptsec", { type: "text/plain" }),
);
element.dispatchEvent(
new DragEvent("drop", {
bubbles: true,
cancelable: true,
dataTransfer,
}),
);
}, `${mockNcryptsec}\n`);
await expect(
backupDialog.getByTestId("backup-password-timeline"),
).toBeVisible();
const passphraseSection = backupDialog.getByTestId(
"nostr-import-passphrase-section",
);
await expect(passphraseSection).toBeVisible();
const passphraseSectionHeight = await passphraseSection.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).height),
);
expect(passphraseSectionHeight).toBe(backupFileSectionHeight);
await expect(
backupDialog.getByTestId("nostr-import-passphrase"),
).toBeFocused();
// Back first returns to backup-file selection instead of closing the dialog.
await backupDialog.getByRole("button", { name: "Back", exact: true }).click();
await expect(
backupDialog.getByRole("heading", { name: "Restore from a backup file" }),
).toBeVisible();
await expect(
backupDialog.getByTestId("nostr-import-backup-picker"),
).toBeVisible();
await fileInput.setInputFiles({
buffer: Buffer.from(`${mockNcryptsec}\n`),
mimeType: "text/plain",
name: "identity.ncryptsec",
});
await backupDialog
.getByTestId("nostr-import-passphrase")
.fill("mock horse battery staple lake orbit");
await backupDialog.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
});
test("non-local runtime override keeps community selection without release flag", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(page, undefined, {
relayWsUrl: "wss://override.example.com",
skipOnboardingSeed: true,
skipCommunitySeed: true,
});
await page.goto("/");
await expect(
page.getByRole("button", { name: /Join a community/ }),
).toBeVisible();
await expect
.poll(() => page.evaluate(() => localStorage.getItem("buzz-communities")))
.toBeNull();
});
test("non-local default auto-connects when the release flag is enabled", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(page, undefined, {
relayWsUrl: "wss://default.example.com",
autoConnectDefaultRelay: true,
skipOnboardingSeed: true,
skipCommunitySeed: true,
});
await page.goto("/");
await expectIncompleteOnboarding(page);
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem("buzz-communities");
const communities = raw
? (JSON.parse(raw) as Array<{ id: string; relayUrl: string }>)
: [];
return {
activeMatchesCommunity:
communities.length === 1 &&
window.localStorage.getItem("buzz-active-community-id") ===
communities[0]?.id,
relayUrl: communities[0]?.relayUrl ?? null,
};
}),
)
.toEqual({
activeMatchesCommunity: true,
relayUrl: "wss://default.example.com",
});
});
test("first-community choices route join, create, owner, and member intents", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(page, undefined, {
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
});
await page.goto("/");
await expect(
page.getByRole("button", { name: /Join a community/ }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /Create a community/ }),
).toBeVisible();
const existing = page.getByRole("button", {
name: /I already have a community/,
});
await expect(existing).toBeVisible();
await existing.click();
// Owner/member split lives on its own page, mirroring the hub layout.
await expect(
page.getByRole("heading", { name: "Reconnect to your community" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "I own the community" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Im a member or admin" }),
).toBeVisible();
await page.getByRole("button", { name: "Im a member or admin" }).click();
await expect(
page.getByRole("heading", { name: "Reconnect to your community" }),
).toBeVisible();
const accessInput = page.getByTestId("invite-redeem-input");
await expect(accessInput).toHaveAttribute(
"placeholder",
"Invite link or community URL",
);
await accessInput.fill("https://default.example.com");
await expect(page.getByTestId("invite-redeem-submit")).toBeEnabled();
// Back from the member form returns to the role choice, then to the hub.
await page.getByRole("button", { name: "Back" }).click();
await expect(
page.getByRole("button", { name: "I own the community" }),
).toBeVisible();
await page.getByTestId("existing-back").click();
await page.getByRole("button", { name: /Join a community/ }).click();
await expect(
page.getByRole("heading", { name: "Join a community" }),
).toBeVisible();
await expect(page.getByText("Joining a private community?")).toBeVisible();
await expect(page.getByTestId("welcome-join-npub")).toBeVisible();
await expect(
page.getByRole("button", { name: "Copy public ID" }),
).toBeVisible();
await accessInput.fill("https://default.example.com/invite/abc123");
await expect(page.getByTestId("invite-redeem-submit")).toBeEnabled();
});
test("first-community owner can connect an existing hosted community", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(
page,
{
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: { pubkey_hex: BLANK_TYLER_IDENTITY.pubkey },
builderlabCommunities: [
{
id: "owned-community",
name: "North Star",
normalized_host: "north-star.communities.buzz.xyz",
},
],
},
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-choice-create").click();
await expect(page.getByText("North Star")).toBeVisible();
await page.getByRole("button", { name: "Connect", exact: true }).click();
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(() =>
window.localStorage.getItem("buzz-community-onboarding-transaction.v1"),
),
)
.toContain('"source":"first-community"');
await expect
.poll(() =>
page.evaluate(() =>
window.localStorage.getItem("buzz-community-onboarding-transaction.v1"),
),
)
.toContain("wss://north-star.communities.buzz.xyz");
await page.getByTestId("community-profile-back").click();
await expect(
page.getByRole("heading", { name: "Choose a community" }),
).toBeVisible();
await expect(page.getByText("North Star")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Join a community" }),
).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() =>
window.localStorage.getItem("buzz-community-onboarding-transaction.v1"),
),
)
.toBeNull();
});
test("first-community owner can create and connect a hosted community", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(
page,
{},
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-choice-create").click();
await page.getByRole("button", { name: "Sign in to continue" }).click();
await expect(
page.getByRole("heading", { name: "Finish connecting Buzz" }),
).toBeVisible();
await page.getByRole("button", { name: "Connect and continue" }).click();
const createSurface = page.getByTestId("hosted-community-create-surface");
const surfaceBoxBeforeFeedback = await createSurface.boundingBox();
const communityNameInput = page.getByTestId("hosted-community-address-input");
await communityNameInput.fill("bee-lab");
await expect(communityNameInput).toHaveAttribute("style", /width: 7ch;/);
const availabilityFeedback = page.getByText("That address is available.");
await expect(availabilityFeedback).toBeVisible();
const [feedbackBox, surfaceBox, inputBox, suffixBox] = await Promise.all([
availabilityFeedback.boundingBox(),
createSurface.boundingBox(),
page.getByTestId("hosted-community-address-input").boundingBox(),
page.locator("#hosted-community-suffix").boundingBox(),
]);
if (
!surfaceBoxBeforeFeedback ||
!feedbackBox ||
!surfaceBox ||
!inputBox ||
!suffixBox
) {
throw new Error("Could not measure hosted community creation layout");
}
expect(surfaceBox.y).toBe(surfaceBoxBeforeFeedback.y);
expect(surfaceBox.height).toBe(surfaceBoxBeforeFeedback.height);
const addressLeft = inputBox.x;
const addressRight = suffixBox.x + suffixBox.width;
expect(
Math.abs(
(addressLeft + addressRight) / 2 - (surfaceBox.x + surfaceBox.width / 2),
),
).toBeLessThanOrEqual(1);
expect(feedbackBox.y).toBeGreaterThanOrEqual(
surfaceBox.y + surfaceBox.height,
);
await page.getByRole("button", { name: "Next" }).click();
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(() =>
window.localStorage.getItem("buzz-community-onboarding-transaction.v1"),
),
)
.toContain("wss://bee-lab.communities.buzz.xyz");
});
test("hosted community address line stays within the card for a long name", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(
page,
{},
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
// The 800px app minimum is the worst case for the full-width address line.
await page.setViewportSize({ width: 800, height: 720 });
await page.goto("/");
await page.getByTestId("community-choice-create").click();
await page.getByRole("button", { name: "Sign in to continue" }).click();
await expect(
page.getByRole("heading", { name: "Finish connecting Buzz" }),
).toBeVisible();
await page.getByRole("button", { name: "Connect and continue" }).click();
const createSurface = page.getByTestId("hosted-community-create-surface");
const communityNameInput = page.getByTestId("hosted-community-address-input");
// A maximum-length (63 char) valid name — the overflow case Wes flagged; the
// 7-char check above cannot catch it.
const longName = "a".repeat(63);
await communityNameInput.fill(longName);
await expect(communityNameInput).toHaveValue(longName);
const [surfaceBox, inputBox, suffixBox] = await Promise.all([
createSurface.boundingBox(),
communityNameInput.boundingBox(),
page.locator("#hosted-community-suffix").boundingBox(),
]);
if (!surfaceBox || !inputBox || !suffixBox) {
throw new Error("Could not measure hosted community creation layout");
}
const addressLeft = inputBox.x;
const addressRight = suffixBox.x + suffixBox.width;
// The composed `<name>.<suffix>` line must stay within the card — no
// horizontal overflow past the surface or the 800px window.
expect(addressLeft).toBeGreaterThanOrEqual(surfaceBox.x);
expect(addressRight).toBeLessThanOrEqual(surfaceBox.x + surfaceBox.width);
expect(addressRight).toBeLessThanOrEqual(800);
// …and it stays centered within the card.
expect(
Math.abs(
(addressLeft + addressRight) / 2 - (surfaceBox.x + surfaceBox.width / 2),
),
).toBeLessThanOrEqual(2);
});
test("first-community reports a created community without a relay address", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(
page,
{
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: { pubkey_hex: BLANK_TYLER_IDENTITY.pubkey },
builderlabCreatedCommunity: {
id: "hosted-bee-lab",
name: "bee-lab",
},
},
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-choice-create").click();
await page.getByRole("textbox", { name: "Community name" }).fill("bee-lab");
await expect(page.getByText("That address is available.")).toBeVisible();
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByRole("alert")).toContainText(
"The community was created, but Builderlab did not return its relay address.",
);
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toHaveCount(0);
});
test("first-community X cancels a pending sign-in", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(
page,
{ builderlabLoginDelayMs: 5_000 },
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-choice-create").click();
await page.getByRole("button", { name: "Sign in to continue" }).click();
await expect(page.getByText("Waiting for your browser…")).toBeVisible();
await expect(
page.getByRole("button", { name: "Cancel sign-in" }),
).toHaveCount(0);
await page.getByRole("button", { name: "Close" }).click();
await expect(
page.getByRole("button", { name: /Create a community/ }),
).toBeVisible();
await expect
.poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? []))
.toEqual(expect.arrayContaining(["cancel_builderlab_login"]));
});
test("first-community owner can replace a mismatched account identity", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(
page,
{
builderlabAuth: {
email: "old-owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: { pubkey_hex: "f".repeat(64) },
},
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-choice-create").click();
await expect(
page.getByRole("heading", {
name: "This account uses a different Buzz identity",
}),
).toBeVisible();
await page
.getByRole("button", { name: "Use this device's identity" })
.click();
await expect(
page.getByRole("textbox", { name: "Community name" }),
).toBeVisible();
await expect
.poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? []))
.toEqual(
expect.arrayContaining([
"delete_builderlab_nostr_identity",
"bind_builderlab_nostr_identity",
]),
);
});
test("first-community explains when the local identity belongs to another account", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(
page,
{
builderlabAuth: {
email: "wrong-owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: { pubkey_hex: "e".repeat(64) },
builderlabBindError: { code: "pubkey_already_bound" },
},
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-choice-create").click();
await page
.getByRole("button", { name: "Use this device's identity" })
.click();
await expect(
page.getByText(
"This device's Buzz identity belongs to a different Builderlab account and can't be moved from here. Sign out, then sign in with the account that already owns this identity.",
),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Finish connecting Buzz" }),
).toBeVisible();
});
test("back clears Builderlab auth before returning to first-community choices", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(
page,
{
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: { pubkey_hex: BLANK_TYLER_IDENTITY.pubkey },
},
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-choice-create").click();
await page.getByRole("button", { name: "Back" }).click();
await page.getByTestId("community-choice-create").click();
await expect(page.getByRole("button", { name: "Continue" })).toBeVisible();
});
test("first-community shows the scenario cards for localhost", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(
page,
{
acpRuntimesCatalog: [
{
id: "claude",
label: "Claude Code",
avatar_url: "",
availability: "available",
command: "claude",
binary_path: "/usr/local/bin/claude",
default_args: [],
mcp_command: null,
install_hint: "Install Claude Code",
install_instructions_url: "https://example.com",
can_auto_install: true,
underlying_cli_path: null,
node_required: false,
auth_status: { status: "logged_in" },
login_hint: "Sign in to Claude Code",
},
],
},
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await expect(
page.getByRole("button", { name: "Join default community" }),
).toHaveCount(0);
await expect(
page.getByRole("button", { name: /Join a community/ }),
).toBeVisible();
await expect(
page.getByRole("button", {
name: /Create a community/,
}),
).toBeVisible();
await page.getByTestId("welcome-setup-back").click();
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
await expect(
page.getByRole("heading", {
name: "Configure your default model settings",
}),
).toBeVisible();
await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
"Claude Code",
);
await expect(page.getByTestId("onboarding-finish")).toBeEnabled();
});
test("first-community direct join reaches profile", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(page, undefined, {
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: /Join a community/ }).click();
await page
.getByTestId("invite-redeem-input")
.fill("wss://onboarding.communities.buzz.xyz");
await page.getByTestId("invite-redeem-submit").click();
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
await expect(page.getByText("Connecting securely…")).toHaveCount(0);
await expect(page.getByText("Create an identity key")).toHaveCount(0);
await expect
.poll(() =>
page.evaluate((transactionStorageKey) => {
const communitiesRaw = window.localStorage.getItem("buzz-communities");
const transactionRaw = window.localStorage.getItem(
transactionStorageKey,
);
const communities = communitiesRaw
? (JSON.parse(communitiesRaw) as Array<{ id: string }>)
: [];
const transaction = transactionRaw
? (JSON.parse(transactionRaw) as { communityId?: string })
: null;
return {
communityCount: communities.length,
transactionMatchesOnlyCommunity:
communities.length === 1 &&
transaction?.communityId === communities[0]?.id,
};
}, COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY),
)
.toEqual({ communityCount: 1, transactionMatchesOnlyCommunity: true });
});
test("community onboarding reuses an existing relay profile", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript(
({ pubkey, transactionStorageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
transactionStorageKey,
JSON.stringify({
id: "txn-existing-profile",
source: "add-community",
stage: "profile",
relayUrl: "wss://onboarding.communities.buzz.xyz",
communityName: "Onboarding",
communityId: "e2e-default-community",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{
pubkey: BLANK_TYLER_IDENTITY.pubkey,
transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
},
);
await installMockBridge(
page,
{ profileHasEvent: true },
{
relayWsUrl: "wss://onboarding.communities.buzz.xyz",
skipOnboardingSeed: true,
},
);
await page.goto("/");
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }
).__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "get_profile",
).length ?? 0,
),
)
.toBeGreaterThan(0);
await expect(
page.getByRole("heading", { name: "Meet your starter team" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toHaveCount(0);
});
test("first-community direct join cancel returns to request access", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(
page,
{ applyCommunityDelayMs: 5_000 },
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.getByRole("button", { name: /Join a community/ }).click();
await page
.getByTestId("invite-redeem-input")
.fill("wss://onboarding.communities.buzz.xyz");
await page.getByTestId("invite-redeem-submit").click();
await expect(page.getByText("Connecting securely…")).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(
page.getByRole("heading", { name: "Join a community" }),
).toBeVisible();
await expect(page.getByTestId("community-change-overlay")).toHaveCount(0);
await expect(page.getByText("Create an identity key")).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(
(storageKey) => ({
communities: window.localStorage.getItem("buzz-communities"),
transaction: window.localStorage.getItem(storageKey),
}),
COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
),
)
.toEqual({ communities: null, transaction: null });
});
test("canceling a join to an existing inactive community preserves it", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript(
({ pubkey, relayUrl }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
"buzz-communities",
JSON.stringify([
{
id: "active-community",
name: "Active",
relayUrl: "wss://active.example.com",
addedAt: timestamp,
},
{
id: "existing-community",
name: "Existing",
relayUrl,
addedAt: timestamp,
},
]),
);
window.localStorage.setItem(
"buzz-active-community-id",
"active-community",
);
},
{
pubkey: BLANK_TYLER_IDENTITY.pubkey,
relayUrl: "wss://onboarding.communities.buzz.xyz",
},
);
await installMockBridge(
page,
{ applyCommunityDelayMs: 5_000 },
{
relayWsUrl: "wss://active.example.com",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.evaluate((transactionStorageKey) => {
const timestamp = new Date().toISOString();
window.localStorage.setItem(
transactionStorageKey,
JSON.stringify({
id: "existing-community-join",
source: "add-community",
stage: "connecting",
relayUrl: "wss://onboarding.communities.buzz.xyz",
communityName: "Existing",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
window.location.reload();
}, COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY);
await expect(page.getByText("Connecting securely…")).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByText("Connecting securely…")).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem("buzz-communities");
return raw
? (JSON.parse(raw) as Array<{ id: string }>).map(({ id }) => id)
: [];
}),
)
.toEqual(["active-community", "existing-community"]);
});
test("connected first-community profile step offers equal-width Next and Back controls", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript(
({ pubkey, transactionStorageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
transactionStorageKey,
JSON.stringify({
id: "txn-profile-step",
source: "first-community",
stage: "profile",
relayUrl: "ws://localhost:3000",
communityName: "Default",
communityId: "e2e-default-community",
addedCommunity: true,
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{
pubkey: BLANK_TYLER_IDENTITY.pubkey,
transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
},
);
await installFakeCamera(page, { failRequests: 1 });
const uploadedAvatarUrl = "https://mock.relay/media/community-avatar.png";
let avatarRequestCount = 0;
await page.route(`${uploadedAvatarUrl}*`, async (route) => {
avatarRequestCount += 1;
if (avatarRequestCount === 1) {
await route.fulfill({ status: 404 });
return;
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
await route.fulfill({
body: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
contentType: "image/png",
});
});
await installMockBridge(
page,
{
uploadDelayMs: 1_000,
uploadDescriptors: [
{
filename: "community-avatar.png",
sha256: "c".repeat(64),
size: 128,
type: "image/png",
uploaded: 1_779_900_000,
url: "https://mock.relay/media/community-avatar.png",
},
],
},
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
},
);
await page.goto("/");
await expect(page.getByTestId("community-onboarding-flow")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
const profileMain = page.getByTestId("community-profile-main");
const profileHeading = page.getByRole("heading", {
name: "Build your profile",
});
await expect(profileHeading).toBeVisible();
const profileHeadingBox = await profileHeading.boundingBox();
if (!profileHeadingBox) {
throw new Error("Could not measure community profile heading position");
}
expect(Math.abs(profileHeadingBox.y - 106)).toBeLessThan(8);
const nameKey = page.getByTestId("community-profile-name-key");
const avatarButton = page.getByTestId("community-avatar-open");
await expect(nameKey).toBeVisible();
await expect(avatarButton).toBeVisible();
const nameKeyBox = await nameKey.boundingBox();
const avatarButtonBox = await avatarButton.boundingBox();
expect(nameKeyBox?.width).toBeGreaterThan(380);
expect(avatarButtonBox?.width).toBe(144);
const nameKeyStyles = await nameKey.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderRadius: styles.borderRadius,
boxShadow: styles.boxShadow,
fontSize: styles.fontSize,
};
});
expect(nameKeyStyles.backgroundColor).toMatch(
/^(rgba\(255, 255, 255, 0\.95\)|oklab\(.+ \/ 0\.95\))$/,
);
expect(nameKeyStyles.borderColor).toBe("rgba(113, 113, 6, 0.28)");
expect(nameKeyStyles.boxShadow).toContain(
"rgba(113, 113, 6, 0.5) 0px 0px 0px 1px inset",
);
expect(nameKeyStyles).toMatchObject({
borderRadius: "16px",
fontSize: "14px",
});
await expect(page.getByText("Your username", { exact: true })).toBeVisible();
await expect(page.getByTestId("community-onboarding-flow")).toHaveAttribute(
"data-system-color-scheme",
/^(light|dark)$/,
);
await page.emulateMedia({ colorScheme: "dark" });
await expect(page.getByTestId("community-onboarding-flow")).toHaveAttribute(
"data-system-color-scheme",
"dark",
);
await avatarButton.click();
const avatarDialog = page.getByRole("dialog", { name: "Edit your avatar" });
await expect(avatarDialog).toBeVisible();
await expect(avatarDialog).toHaveAttribute(
"data-system-color-scheme",
"light",
);
const dialogStyles = await avatarDialog.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backgroundColor: styles.backgroundColor,
boxShadow: styles.boxShadow,
color: styles.color,
};
});
expect(dialogStyles.backgroundColor).toBe("rgb(255, 255, 255)");
expect(dialogStyles.color).toBe("rgb(23, 23, 23)");
expect(dialogStyles.boxShadow).not.toBe("none");
const dialogOverlay = page.getByTestId("dialog-overlay");
const overlayStyles = await dialogOverlay.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backdropFilter: styles.backdropFilter,
backgroundColor: styles.backgroundColor,
};
});
expect(overlayStyles.backgroundColor).toBe("rgba(0, 0, 0, 0)");
expect(overlayStyles.backdropFilter).toBe("none");
const dialogLayout = await avatarDialog.evaluate((element) => ({
clientHeight: element.clientHeight,
clientWidth: element.clientWidth,
scrollHeight: element.scrollHeight,
}));
const editorWidth = await page
.getByTestId("community-avatar-editor")
.evaluate((element) => element.clientWidth);
const uploadHeight = await page
.getByTestId("community-avatar-upload")
.evaluate((element) => element.clientHeight);
const urlBox = await page.getByTestId("community-avatar-url").boundingBox();
const dialogBox = await avatarDialog.boundingBox();
if (!dialogBox || !urlBox) {
throw new Error("Could not measure avatar dialog layout");
}
expect(dialogLayout.clientWidth).toBeLessThanOrEqual(560);
const imageDialogHeight = dialogLayout.clientHeight;
const dialogTransition = await avatarDialog.evaluate(
(element) => window.getComputedStyle(element).transitionProperty,
);
expect(dialogTransition).toContain("height");
expect(editorWidth).toBe(456);
expect(uploadHeight).toBe(126);
expect(dialogLayout.scrollHeight).toBeLessThanOrEqual(
dialogLayout.clientHeight,
);
expect(urlBox.y).toBeGreaterThanOrEqual(dialogBox.y);
expect(urlBox.y + urlBox.height).toBeLessThanOrEqual(
dialogBox.y + dialogBox.height,
);
const saveButton = page.getByTestId("community-avatar-done");
await page.getByTestId("community-avatar-input").setInputFiles({
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
mimeType: "image/png",
name: "community-avatar.png",
});
const previewImage = page.getByTestId(
"community-avatar-upload-preview-image",
);
await expect(previewImage).toHaveAttribute("src", /^blob:/);
await expect(saveButton).toBeDisabled();
await expect(saveButton).toHaveText("Save");
const localPreviewUrl = await previewImage.getAttribute("src");
await expect(previewImage).toHaveAttribute("src", localPreviewUrl ?? "");
await saveButton.click();
await expect(avatarDialog).toHaveCount(0);
const avatarCircleImage = page.getByTestId("community-avatar-circle-image");
await expect(avatarCircleImage).toHaveAttribute("src", /^blob:/);
await expect(
page.getByTestId("community-avatar-circle-upload-pending"),
).toBeVisible();
await expect(page.getByTestId("community-profile-name-key")).toBeEnabled();
await expect
.poll(() => avatarCircleImage.getAttribute("src"))
.not.toMatch(/^blob:/);
await expect(
page.getByTestId("community-avatar-circle-upload-pending"),
).toHaveCount(0);
await avatarButton.click();
await expect(avatarDialog).toBeVisible();
await expect(previewImage).toHaveAttribute(
"src",
new RegExp(`^${uploadedAvatarUrl}`),
);
const modeContentShell = page.getByTestId(
"community-avatar-mode-content-shell",
);
await page.waitForTimeout(300);
const measureAnchoredEditorLayout = async () => {
const [tabsBox, contentShellBox, contentBox, saveBox] = await Promise.all([
page.getByRole("tablist", { name: "Avatar type" }).boundingBox(),
modeContentShell.boundingBox(),
modeContentShell.locator(":scope > div").boundingBox(),
saveButton.boundingBox(),
]);
if (!tabsBox || !contentShellBox || !contentBox || !saveBox) {
throw new Error("Could not measure anchored avatar editor layout");
}
return { tabsBox, contentShellBox, contentBox, saveBox };
};
const imageEditorLayout = await measureAnchoredEditorLayout();
expect(
Math.abs(
imageEditorLayout.contentBox.y +
imageEditorLayout.contentBox.height / 2 -
(imageEditorLayout.contentShellBox.y +
imageEditorLayout.contentShellBox.height / 2),
),
).toBeLessThanOrEqual(1);
const saveStyles = await saveButton.evaluate((element) => {
const styles = window.getComputedStyle(element);
return { backgroundColor: styles.backgroundColor, color: styles.color };
});
expect(saveStyles).toEqual({
backgroundColor: "rgb(23, 23, 23)",
color: "rgb(240, 240, 205)",
});
const defaultDialogHeight = imageDialogHeight;
await page.getByRole("tab", { name: "Emoji" }).click();
await expect
.poll(() => avatarDialog.evaluate((element) => element.clientHeight))
.toBe(defaultDialogHeight);
await page.waitForTimeout(300);
const emojiEditorLayout = await measureAnchoredEditorLayout();
expect(emojiEditorLayout.saveBox.y).toBe(imageEditorLayout.saveBox.y);
await page.getByRole("tab", { name: "Animated" }).click();
await expect(saveButton).toHaveCount(0);
await expect(
page.getByTestId("community-avatar-animated-error"),
).toContainText("Could not access the camera");
const retryCameraButton = page.getByTestId("community-avatar-animated-retry");
await expect(retryCameraButton).toHaveText("Try camera again");
await retryCameraButton.click();
const captureButton = page.getByTestId("community-avatar-animated-record");
await expect(captureButton).toHaveText("Capture 3 sec video");
await captureButton.click();
await expect(
page.getByTestId("community-avatar-animated-sections"),
).toBeVisible({ timeout: 60_000 });
await expect(saveButton).toBeVisible();
await page.getByRole("tab", { name: "Emoji" }).click();
await selectFirstEmojiFromPicker(page);
await expect
.poll(() => avatarDialog.evaluate((element) => element.clientHeight))
.toBeGreaterThan(defaultDialogHeight);
await page.waitForTimeout(300);
const selectedEmojiDialogHeight = await avatarDialog.evaluate(
(element) => element.clientHeight,
);
const expandedEmojiLayout = await measureAnchoredEditorLayout();
expect(
expandedEmojiLayout.contentBox.y - expandedEmojiLayout.contentShellBox.y,
).toBeGreaterThanOrEqual(24);
expect(
expandedEmojiLayout.contentShellBox.y +
expandedEmojiLayout.contentShellBox.height -
(expandedEmojiLayout.contentBox.y +
expandedEmojiLayout.contentBox.height),
).toBeGreaterThanOrEqual(24);
expect(
expandedEmojiLayout.contentBox.y -
(expandedEmojiLayout.tabsBox.y + expandedEmojiLayout.tabsBox.height),
).toBeGreaterThanOrEqual(24);
expect(
expandedEmojiLayout.saveBox.y -
(expandedEmojiLayout.contentBox.y +
expandedEmojiLayout.contentBox.height),
).toBeGreaterThanOrEqual(24);
await page.getByTestId("community-avatar-custom-color").click();
await expect
.poll(() => avatarDialog.evaluate((element) => element.clientHeight))
.toBeGreaterThan(selectedEmojiDialogHeight);
await page.getByTestId("community-avatar-custom-color-done").click();
await expect
.poll(() => avatarDialog.evaluate((element) => element.clientHeight))
.toBe(selectedEmojiDialogHeight);
await page.getByRole("tab", { name: "Image" }).click();
await expect
.poll(() => avatarDialog.evaluate((element) => element.clientHeight))
.toBe(imageDialogHeight);
await expect(profileMain).toHaveClass(/opacity-45/);
await expect(profileMain).toHaveClass(/blur-\[3px\]/);
await expect(
page.getByTestId("community-profile-name-key"),
).not.toBeFocused();
await page.keyboard.press("Escape");
await expect(avatarDialog).toHaveCount(0);
await expect(avatarButton).toBeFocused();
const nextButton = page.getByTestId("community-profile-next");
const backButton = page.getByTestId("community-profile-back");
await expect(nextButton).toHaveText("Next");
await expect(nextButton).toBeDisabled();
await expect(backButton).toHaveText("Back");
await expect(backButton).toBeEnabled();
const [nextBox, backBox] = await Promise.all([
nextButton.boundingBox(),
backButton.boundingBox(),
]);
if (!nextBox || !backBox) {
throw new Error("Could not measure community profile navigation controls");
}
expect(Math.abs(nextBox.width - backBox.width)).toBeLessThanOrEqual(1);
expect(nextBox.width).toBeLessThanOrEqual(160);
await backButton.click();
await expect(
page.getByRole("heading", { name: "Join a community" }),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
),
)
.toBeNull();
});
test("name-only community profile save preserves an existing avatar", async ({
page,
}) => {
await seedCommunityProfileStage(page, "txn-avatar-preserve-existing");
await installMockBridge(page, undefined, {
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
});
await page.goto("/");
const existingAvatarUrl =
"https://mock.relay/media/existing-community-avatar.png";
await seedCurrentAvatar(page, existingAvatarUrl);
await page.getByTestId("community-profile-name-key").fill("Tyler");
await page.getByTestId("community-profile-next").click();
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])
.filter(
({ command }) =>
command === "update_profile" ||
command === "update_profile_at_relay",
)
.map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl),
),
)
.toEqual([undefined]);
const profile = await invokeMockCommand<{ avatar_url: string | null }>(
page,
"get_profile",
);
expect(profile.avatar_url).toBe(existingAvatarUrl);
});
test("pending avatar stays navigable, clears failures, and retries", async ({
page,
}) => {
await seedCommunityProfileStage(page, "txn-avatar-propagation");
const uploadedAvatarUrl =
"https://mock.relay/media/pending-community-avatar.png";
let avatarReady = false;
await page.route(`${uploadedAvatarUrl}*`, async (route) => {
if (!avatarReady) {
await route.fulfill({ status: 404 });
return;
}
await new Promise((resolve) => setTimeout(resolve, 500));
await route.fulfill({
body: Buffer.from(ONE_PIXEL_PNG_BASE64, "base64"),
contentType: "image/png",
});
});
await installMockBridge(
page,
{
uploadDelayMs: 250,
uploadDescriptors: [
{
filename: "pending-community-avatar.png",
sha256: "d".repeat(64),
size: 128,
type: "image/png",
uploaded: 1_779_900_000,
url: uploadedAvatarUrl,
},
],
},
{
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-profile-name-key").fill("Tyler");
await uploadCommunityAvatar(page, "pending-community-avatar.png");
const avatarImage = page.getByTestId("community-avatar-circle-image");
await expect(avatarImage).toHaveAttribute("src", /^blob:/);
await expect(
page.getByTestId("community-avatar-circle-upload-pending"),
).toBeVisible();
await expect(avatarImage).toHaveClass(/brightness-75/);
await expect(page.getByTestId("community-profile-next")).toBeEnabled();
await page.waitForTimeout(500);
await expect(
page.getByTestId("community-avatar-circle-upload-pending"),
).toBeVisible();
const avatar = page.getByTestId("community-avatar-circle");
const pendingSpinner = page
.getByTestId("community-avatar-circle-upload-pending")
.locator(".sprout-arc-spinner");
const [avatarBox, spinnerBox] = await Promise.all([
avatar.boundingBox(),
pendingSpinner.boundingBox(),
]);
if (!avatarBox || !spinnerBox) {
throw new Error("Could not measure pending avatar spinner");
}
expect(
Math.abs(
avatarBox.x + avatarBox.width / 2 - (spinnerBox.x + spinnerBox.width / 2),
),
).toBeLessThanOrEqual(1);
expect(
Math.abs(
avatarBox.y +
avatarBox.height / 2 -
(spinnerBox.y + spinnerBox.height / 2),
),
).toBeLessThanOrEqual(1);
expect(spinnerBox.width / avatarBox.width).toBeLessThanOrEqual(0.2);
await expect(page.getByTestId("community-avatar-empty")).toBeVisible({
timeout: 10_000,
});
await expect(
page.getByRole("button", { name: "Add an avatar" }),
).toBeVisible();
await expect(
page.getByTestId("community-avatar-circle-fallback"),
).toHaveCount(0);
await expect(avatarImage).toHaveCount(0);
await expect(
page.getByText("Avatar couldnt finish uploading"),
).toBeVisible();
await expect(
page.getByText("Your default avatar is showing instead."),
).toBeVisible();
await page.getByTestId("community-profile-next").click();
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])
.filter(
({ command }) =>
command === "update_profile" ||
command === "update_profile_at_relay",
)
.map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl),
),
)
.toEqual([undefined]);
avatarReady = true;
await page.getByRole("button", { name: "Retry" }).click();
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])
.filter(
({ command }) =>
command === "update_profile" ||
command === "update_profile_at_relay",
)
.map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl),
),
)
.toEqual([undefined, uploadedAvatarUrl]);
await expect(page.getByText("Avatar couldnt finish uploading")).toHaveCount(
0,
);
});
test("a pending avatar never becomes durable if propagation fails after onboarding unmounts", async ({
page,
}) => {
await seedCommunityProfileStage(page, "txn-avatar-saved-before-failure");
const uploadedAvatarUrl =
"https://mock.relay/media/saved-pending-community-avatar.png";
let allowAvatarFailure = false;
await page.route(`${uploadedAvatarUrl}*`, async (route) => {
while (!allowAvatarFailure) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
await route.fulfill({ status: 404 });
});
await installMockBridge(
page,
{
uploadDescriptors: [
{
filename: "saved-pending-community-avatar.png",
sha256: "f".repeat(64),
size: 128,
type: "image/png",
uploaded: 1_779_900_002,
url: uploadedAvatarUrl,
},
],
},
{
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-profile-name-key").fill("Tyler");
await uploadCommunityAvatar(page, "saved-pending-community-avatar.png");
await expect(
page.getByTestId("community-avatar-circle-upload-pending"),
).toBeVisible();
await page.getByTestId("community-profile-next").click();
await page.getByTestId("community-team-intro-enter").click();
await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0, {
timeout: 10_000,
});
allowAvatarFailure = true;
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])
.filter(
({ command }) =>
command === "update_profile" ||
command === "update_profile_at_relay",
)
.map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl),
),
)
.toEqual([undefined]);
await expect(
page.getByText("Avatar couldnt finish uploading"),
).toBeVisible();
const profile = await invokeMockCommand<{ avatar_url: string | null }>(
page,
"get_profile",
);
expect(profile.avatar_url).toBeNull();
});
test("a pending avatar becomes durable after onboarding unmounts once ready", async ({
page,
}) => {
await seedCommunityProfileStage(page, "txn-avatar-ready-after-unmount");
const uploadedAvatarUrl =
"https://mock.relay/media/ready-after-unmount-community-avatar.png";
let allowAvatarReady = false;
await page.route(`${uploadedAvatarUrl}*`, async (route) => {
while (!allowAvatarReady) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
await route.fulfill({
body: Buffer.from(ONE_PIXEL_PNG_BASE64, "base64"),
contentType: "image/png",
});
});
await installMockBridge(
page,
{
uploadDescriptors: [
{
filename: "ready-after-unmount-community-avatar.png",
sha256: "b".repeat(64),
size: 128,
type: "image/png",
uploaded: 1_779_900_004,
url: uploadedAvatarUrl,
},
],
},
{
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-profile-name-key").fill("Tyler");
await uploadCommunityAvatar(page, "ready-after-unmount-community-avatar.png");
await page.getByTestId("community-profile-next").click();
await page.getByTestId("community-team-intro-enter").click();
await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0, {
timeout: 10_000,
});
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])
.filter(
({ command }) =>
command === "update_profile" ||
command === "update_profile_at_relay",
)
.map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl),
),
)
.toEqual([undefined]);
allowAvatarReady = true;
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])
.filter(
({ command }) =>
command === "update_profile" ||
command === "update_profile_at_relay",
)
.map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl),
),
)
.toEqual([undefined, uploadedAvatarUrl]);
const profile = await invokeMockCommand<{ avatar_url: string | null }>(
page,
"get_profile",
);
expect(profile.avatar_url).toBe(uploadedAvatarUrl);
});
test("a failed pending replacement leaves the confirmed avatar untouched", async ({
page,
}) => {
await seedCommunityProfileStage(page, "txn-avatar-restore-existing");
const existingAvatarUrl =
"https://mock.relay/media/existing-community-avatar.png";
const uploadedAvatarUrl =
"https://mock.relay/media/replacement-community-avatar.png";
await page.route(`${uploadedAvatarUrl}*`, (route) =>
route.fulfill({ status: 404 }),
);
await installMockBridge(
page,
{
uploadDescriptors: [
{
filename: "replacement-community-avatar.png",
sha256: "a".repeat(64),
size: 128,
type: "image/png",
uploaded: 1_779_900_003,
url: uploadedAvatarUrl,
},
],
},
{
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
},
);
await page.goto("/");
await seedCurrentAvatar(page, existingAvatarUrl);
await page.getByTestId("community-profile-name-key").fill("Tyler");
await uploadCommunityAvatar(page, "replacement-community-avatar.png");
await page.getByTestId("community-profile-next").click();
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])
.filter(
({ command }) =>
command === "update_profile" ||
command === "update_profile_at_relay",
)
.map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl),
),
)
.toEqual([undefined]);
const profile = await invokeMockCommand<{ avatar_url: string | null }>(
page,
"get_profile",
);
expect(profile.avatar_url).toBe(existingAvatarUrl);
});
test("replacing a pending upload disposes its verifier and local preview", async ({
page,
}) => {
await seedCommunityProfileStage(page, "txn-avatar-replacement");
await page.addInitScript(() => {
const testWindow = window as Window & {
__BUZZ_E2E_REVOKED_OBJECT_URLS__?: string[];
};
const revokedUrls: string[] = [];
const revokeObjectUrl = URL.revokeObjectURL.bind(URL);
testWindow.__BUZZ_E2E_REVOKED_OBJECT_URLS__ = revokedUrls;
URL.revokeObjectURL = (url) => {
revokedUrls.push(url);
revokeObjectUrl(url);
};
});
const uploadedAvatarUrl =
"https://mock.relay/media/superseded-community-avatar.png";
await page.route(`${uploadedAvatarUrl}*`, (route) =>
route.fulfill({ status: 404 }),
);
await installMockBridge(
page,
{
uploadDescriptors: [
{
filename: "superseded-community-avatar.png",
sha256: "e".repeat(64),
size: 128,
type: "image/png",
uploaded: 1_779_900_001,
url: uploadedAvatarUrl,
},
],
},
{
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-profile-name-key").fill("Tyler");
await uploadCommunityAvatar(page, "superseded-community-avatar.png");
const supersededPreviewUrl = await page
.getByTestId("community-avatar-circle-image")
.getAttribute("src");
expect(supersededPreviewUrl).toMatch(/^blob:/);
await page.getByTestId("community-avatar-open").click();
await page.getByRole("tab", { name: "Emoji" }).click();
await selectFirstEmojiFromPicker(page);
await page.getByTestId("community-avatar-done").click();
await expect
.poll(() =>
page.evaluate(() => {
const testWindow = window as Window & {
__BUZZ_E2E_REVOKED_OBJECT_URLS__?: string[];
};
return testWindow.__BUZZ_E2E_REVOKED_OBJECT_URLS__ ?? [];
}),
)
.toContain(supersededPreviewUrl);
await page.waitForTimeout(6_000);
await expect(page.getByText("Avatar couldnt finish uploading")).toHaveCount(
0,
);
await expect(page.getByRole("button", { name: "Retry" })).toHaveCount(0);
});
test("membership denial on community profile save offers recovery", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript(
({ pubkey, transactionStorageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
transactionStorageKey,
JSON.stringify({
id: "txn-membership-denied",
source: "first-community",
stage: "profile",
relayUrl: "wss://denied.example.com",
communityName: "Denied",
communityId: "e2e-default-community",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{
pubkey: BLANK_TYLER_IDENTITY.pubkey,
transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
},
);
await installMockBridge(
page,
{
profileUpdateError:
"relay returned 403 Forbidden: You must be a relay member to access this relay",
},
{
relayWsUrl: "wss://denied.example.com",
skipOnboardingSeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-profile-name-key").fill("Kalvin");
await page.getByTestId("community-profile-next").click();
await expect(page.getByTestId("membership-denied")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Not a member yet" }),
).toBeVisible();
await page.getByRole("button", { name: "Change community" }).click();
await expect(page.getByTestId("community-change-overlay")).toBeVisible();
await page.getByLabel("Community URL").fill("wss://invited.example.com");
await page.getByRole("button", { name: "Save changes" }).click();
await page.getByRole("button", { name: "Use anyway" }).click();
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
),
)
.toContain("wss://invited.example.com");
});
test("identity fallback text does not count as a real onboarding name", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await expectIncompleteOnboarding(page);
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
});
// Regression test for the H2 predicate fix (PR #1508).
// A blank first-run identity (no kind:0 event on the relay) must see
// onboarding even when the mock bridge returns display_name: "" — the gate
// must depend on `hasProfileEvent`, not on `typeof displayName === "string"`.
test("first-run blank identity with no profile event sees onboarding", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
// Do NOT seed searchProfiles for tyler's pubkey, so ensureMockProfile
// constructs a synthesised profile with has_profile_event: false.
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await expectIncompleteOnboarding(page);
});
// Regression test for the H2 predicate fix (PR #1508).
// A returning user who has a real kind:0 profile event with an empty
// display_name must skip onboarding — they are already onboarded.
test("returning user with blank display name and real profile event skips onboarding", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
// Seed tyler's pubkey into searchProfiles with an empty displayName.
// seedMockSearchProfiles stores this into mockProfiles with
// has_profile_event: true, simulating a real kind:0 event with no name.
await installMockBridge(
page,
{
searchProfiles: [
{
pubkey: TEST_IDENTITIES.tyler.pubkey,
displayName: "",
},
],
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
// Profile event exists → onboarding is skipped, app renders.
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectHomeView(page);
});
// Regression test for the cache-seed defect (PR #1508 CRITICAL fix).
// Sequence: no-event profile fetched and cached with hasProfileEvent absent →
// reload with cache present → onboarding must still show. Previously the
// initialData seed hardcoded hasProfileEvent: true for any updatedAt > 0
// entry, reopening the original bug on the second app load.
test("no-event profile cached then reloaded still sees onboarding", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
// Seed a stale v1 cache entry WITHOUT hasProfileEvent (simulating a cache
// written by the old code path or a no-event fallback). updatedAt > 0 so
// the seed is eligible, but hasProfileEvent is absent → conservative false.
const SELF_PROFILE_CACHE_KEY = `buzz-self-profile.v1:ws://localhost:3000:${TEST_IDENTITIES.tyler.pubkey}`;
await page.addInitScript(
({ key, cache }) => {
window.localStorage.setItem(key, JSON.stringify(cache));
},
{
key: SELF_PROFILE_CACHE_KEY,
cache: {
version: 1,
displayName: null,
avatarUrl: null,
avatarDataUrl: null,
updatedAt: 1_700_000_000_000,
// hasProfileEvent deliberately absent — legacy/no-event entry.
},
},
);
// No profile event on the relay either — ensureMockProfile uses false.
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
// Cache seed must NOT promote hasProfileEvent to true. Onboarding shows.
await expectIncompleteOnboarding(page);
});
test("avatar step uses an add-image placeholder before an avatar is chosen", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_AVATAR_PLACEHOLDER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
const preview = page.getByTestId("onboarding-avatar-preview");
await expect(preview).toBeVisible();
await expect(preview).toHaveAttribute("aria-label", "Add a display image");
await expect(preview).toHaveClass(/border-dashed/);
});
test("avatar step reveals preset backgrounds after the first emoji pick", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_AVATAR_EMOJI_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page.getByRole("tab", { name: "Emoji" }).click();
const colorGridShell = page.getByTestId("onboarding-avatar-color-grid-shell");
await expect(colorGridShell).toHaveAttribute("aria-hidden", "true");
await selectFirstEmojiFromPicker(page);
await expect(colorGridShell).toHaveAttribute("aria-hidden", "false");
await expect(page.getByTestId("onboarding-avatar-color-grid")).toBeVisible();
await expect(page.getByTestId("onboarding-avatar-preview")).not.toHaveCSS(
"background-color",
"rgb(255, 255, 255)",
);
});
test("avatar step accepts an avatar URL before completing onboarding", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page
.getByTestId("onboarding-avatar-url")
.fill("https://example.com/morty.png");
const preview = page.getByTestId("onboarding-avatar-preview");
await expect(preview).toBeVisible();
const box = await preview.boundingBox();
expect(box?.width).toBeCloseTo(192, 0);
expect(box?.height).toBeCloseTo(192, 0);
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectWelcomeView(page);
});
test("failed avatar saves can continue without saving the avatar", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, {}, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page
.getByTestId("onboarding-avatar-url")
.fill("https://example.com/morty.png");
await page.evaluate(() => {
const testWindow = window as Window & {
__BUZZ_E2E__?: { mock?: { profileUpdateError?: string } };
};
if (testWindow.__BUZZ_E2E__?.mock) {
testWindow.__BUZZ_E2E__.mock.profileUpdateError =
"Temporary avatar sync failure.";
}
});
await page.getByTestId("onboarding-next").click();
await expect(page.getByText("Temporary avatar sync failure.")).toBeVisible();
await expect(
page.getByTestId("onboarding-next-without-saving"),
).toBeVisible();
await page.getByTestId("onboarding-next-without-saving").click();
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectWelcomeView(page);
});
test("avatar upload rejects a file whose server-detected MIME is not an image", async ({
page,
}) => {
// Models a spoofed/blank picker MIME: the picked file claims to be an image
// (passes the browser-side accept filter) but the shared generic upload path
// returns a non-image descriptor. The post-upload backstop must reject it so
// a non-image can't become an avatar (regression guard — the shared upload
// path no longer rejects non-images server-side).
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
uploadDescriptors: [
{
url: `https://mock.relay/media/${"b".repeat(64)}.pdf`,
sha256: "b".repeat(64),
size: 4096,
type: "application/pdf",
uploaded: Math.floor(Date.now() / 1000),
filename: "not-an-image.pdf",
},
],
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page.getByTestId("onboarding-avatar-input").setInputFiles({
name: "looks-like.png",
mimeType: "image/png",
buffer: Buffer.from("not really a png"),
});
await expect(page.getByRole("alert")).toContainText(
"Choose a PNG, JPG, GIF, or WebP image.",
);
await expect(page.getByTestId("onboarding-avatar-url")).toHaveValue("");
});
test("avatar upload accepts a file whose server-detected MIME is an image", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
const url = `https://mock.relay/media/${"c".repeat(64)}.png`;
await installMockBridge(
page,
{
uploadDescriptors: [
{
url,
sha256: "c".repeat(64),
size: 2048,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
},
],
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page.getByTestId("onboarding-avatar-input").setInputFiles({
name: "avatar.png",
mimeType: "image/png",
buffer: Buffer.from("png bytes"),
});
await expect(page.getByTestId("onboarding-avatar-url")).toHaveValue("");
await expect(
page.getByTestId("onboarding-avatar-preview-fallback"),
).toHaveText("MQ");
await expect(page.getByTestId("onboarding-avatar-error")).toHaveCount(0);
});
test("first-run onboarding keeps the shell hidden and lands on private Welcome after profile setup", async ({
page,
}) => {
await seedActiveIdentity(page, FIRST_RUN_ALICE);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await expect(page.getByTestId("onboarding-gate")).toBeVisible();
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await expect(page.getByTestId("onboarding-display-name")).toHaveValue("");
await expectNoHomeSeenEntries(page);
await page.getByTestId("onboarding-display-name").fill("Alice");
await completeProfileOnboarding(page);
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectWelcomeView(page);
await expectStarterChannels(page);
await expectWelcomeGuideIntro(page);
});
async function commandCount(page: Page, command: string) {
return page.evaluate(
(target) =>
window.__BUZZ_E2E_COMMANDS__?.filter((entry) => entry === target)
.length ?? 0,
command,
);
}
test("failed public starter channel setup does not show a retry toast", async ({
page,
}) => {
const starterError = "Mock starter channel setup failed.";
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{ ensureStarterChannelsErrors: [starterError, starterError] },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await completeProfileOnboarding(page);
await expectPrivateWelcomeLanding(page);
await expect(
page
.locator("[data-sonner-toast][data-removed='false']")
.filter({ hasText: "Couldn't set up starter channels" }),
).toHaveCount(0);
expect(await commandCount(page, "ensure_starter_channels")).toBe(2);
});
test("first-run onboarding posts the live Fizz kickoff", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
globalAgentConfig: {
env_vars: { OPENAI_API_KEY: "e2e-placeholder" },
provider: "openai",
model: "gpt-5.5",
},
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await completeProfileOnboarding(page);
await expectPrivateWelcomeLanding(page);
// Greeted by the name typed above — the @mention pill also files the opener
// into the new user's Inbox mentions feed.
await expect(page.getByTestId("message-timeline")).toContainText(
"Hi @Morty QA, I'm Fizz. Welcome to Buzz.",
);
await expect(page.getByTestId("message-timeline")).toContainText(
"Honey and Bumble, introduce yourselves",
);
});
test("first-run onboarding lands before Welcome team bootstrap completes", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{ createManagedAgentDelayMs: 1_000 },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await completeProfileOnboarding(page);
await expectPrivateWelcomeLanding(page);
await expect(page.getByTestId("app-loading-gate")).toHaveCount(0);
await expect(page.getByTestId("message-timeline")).toContainText(
"Hi @Morty QA, I'm Fizz. Welcome to Buzz.",
);
await page.waitForTimeout(1_500);
expect(await commandCount(page, "create_managed_agent")).toBe(3);
});
test("existing relay profile with display name auto-skips onboarding without localStorage", async ({
page,
}) => {
// A user whose relay profile already has a display name should skip
// onboarding even without the localStorage completion flag.
// Seed alice's pubkey into searchProfiles so seedMockSearchProfiles writes
// has_profile_event: true into mockProfiles — the harness-intended mechanism
// for simulating a returning user with a real kind:0 event. Do NOT use the
// static mockProfiles seed (removed in PR #1508); that path collides with
// FIRST_RUN_ALICE (same pubkey) and breaks the first-run onboarding specs.
await seedActiveIdentity(page, TEST_IDENTITIES.alice);
await installMockBridge(
page,
{
searchProfiles: [
{ pubkey: TEST_IDENTITIES.alice.pubkey, displayName: "alice" },
],
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectHomeView(page);
});
test("onboarding uses the existing identity when the community is already set up", async ({
page,
}) => {
// Community exists (default seed), and machine onboarding has already created
// this identity. Profile setup must not offer to create or replace it.
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await expect(page.getByTestId("onboarding-display-name")).toHaveValue("");
await expect(page.getByTestId("onboarding-next")).toHaveText("Continue");
await expect(page.getByTestId("onboarding-import-key")).toHaveCount(0);
await expect(page.getByText("Create an identity key")).toHaveCount(0);
});
test("completed onboarding backfills missing starter channels", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await seedOnboardingCompletion(page, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectHomeView(page);
await expect(page.getByTestId("channel-general")).toBeVisible();
await expect(page.getByTestId("channel-welcome-everyone")).toBeVisible();
await expectStarterChannels(page);
await expectWelcomeGuideIntro(page, { expectVisible: false });
});
test("finishing onboarding creates starter channels and focuses welcome-everyone for a new member", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await completeProfileOnboarding(page);
await expectWelcomeView(page);
await expect(page.getByTestId("channel-general")).toBeVisible();
await expectStarterChannels(page);
await expectWelcomeGuideIntro(page);
await expectWelcomeComposerBannerCompletesAfterPersonaMention(page);
});
test("welcome-everywhere banner: X dismiss removes the guidance surface", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await completeProfileOnboarding(page);
const banner = page.getByTestId("welcome-composer-guide-banner");
const guidanceLayer = page.getByTestId("welcome-composer-guidance-layer");
const dismissButton = page.getByTestId("welcome-composer-dismiss-button");
// Banner and guidance layer are visible in the prompt state.
await expect(banner).toBeVisible();
await expect(guidanceLayer).toBeVisible();
await expect(dismissButton).toBeVisible();
await dismissButton.click();
// After dismiss the entire guidance surface must be gone.
await expect(banner).toHaveCount(0, { timeout: 2_000 });
await expect(guidanceLayer).toHaveCount(0);
});
test("welcome-everywhere banner: dismiss persists after channel re-entry", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await completeProfileOnboarding(page);
const banner = page.getByTestId("welcome-composer-guide-banner");
await expect(banner).toBeVisible();
await page.getByTestId("welcome-composer-dismiss-button").click();
await expect(banner).toHaveCount(0, { timeout: 2_000 });
// Leave the Welcome channel.
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toContainText("general");
await expect(banner).toHaveCount(0);
// Return — banner must stay hidden.
await page.getByTestId("channel-welcome-everyone").click();
await expect(page.getByTestId("chat-title")).toContainText("Welcome");
await expect(banner).toHaveCount(0);
});
test("initial profile read failures still hold incomplete users in onboarding", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
profileReadError: "Temporary profile read failure.",
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expectIncompleteOnboarding(page);
});
test("failed first profile saves can be skipped for the current session", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
profileUpdateError: "Temporary profile sync failure.",
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("onboarding-gate")).toBeVisible();
await expect(page.getByTestId("onboarding-display-name")).toHaveValue("");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByText("Temporary profile sync failure.")).toBeVisible();
await page.getByTestId("onboarding-skip").click();
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectHomeView(page);
});
test("generic relay save failures use the generic reconnect card", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
profileUpdateError: "relay unreachable: could not connect to relay",
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(
page.getByTestId("onboarding-relay-reconnect-card"),
).toBeVisible();
await expect(
page.getByTestId("onboarding-relay-reconnect-card"),
).toContainText("Can't reach the relay");
await expect(
page.getByTestId("onboarding-relay-reconnect-card"),
).toContainText("Click to connect");
});
test("custom relay proxy sign-in failures use the generic reconnect card", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
profileUpdateError:
"relay unreachable: relay returned an unexpected HTML page (network sign-in?)",
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(
page.getByTestId("onboarding-relay-reconnect-card"),
).toBeVisible();
await expect(
page.getByTestId("onboarding-relay-reconnect-card"),
).toContainText("Can't reach the relay");
});
test("community access failures use the generic reconnect card", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
profileUpdateError: "relay unreachable: 403 Forbidden",
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(
page.getByTestId("onboarding-relay-reconnect-card"),
).toBeVisible();
await expect(
page.getByTestId("onboarding-relay-reconnect-card"),
).toContainText("Can't reach the relay");
});
test("dismissed relay save failures reappear on retry", async ({ page }) => {
const relayError = "relay unreachable: could not connect to relay";
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
profileUpdateErrors: [relayError, relayError],
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(
page.getByTestId("onboarding-relay-reconnect-card"),
).toBeVisible();
await page.getByTestId("onboarding-relay-reconnect-card").hover();
await page.getByTestId("onboarding-relay-reconnect-card-dismiss").click();
await expect(page.getByTestId("onboarding-relay-reconnect-card")).toHaveCount(
0,
);
await page.getByTestId("onboarding-next").click();
await expect(
page.getByTestId("onboarding-relay-reconnect-card"),
).toBeVisible();
});
test("existing relay profile with display name auto-completes onboarding", async ({
page,
}) => {
// A user whose relay profile already has a display name should skip
// onboarding entirely — they've already set up their identity previously
// (possibly on another machine or app data directory).
// Seed alice's pubkey into searchProfiles so seedMockSearchProfiles writes
// has_profile_event: true into mockProfiles — the harness-intended mechanism
// for simulating a returning user with a real kind:0 event. Do NOT use the
// static mockProfiles seed (removed in PR #1508); that path collides with
// FIRST_RUN_ALICE (same pubkey) and breaks the first-run onboarding specs.
await seedActiveIdentity(page, TEST_IDENTITIES.alice);
await installMockBridge(
page,
{
searchProfiles: [
{ pubkey: TEST_IDENTITIES.alice.pubkey, displayName: "alice" },
],
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectHomeView(page);
});
test("open relay skips membership gating during onboarding", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
relayRequiresMembership: false,
relayRole: null,
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await expect(page.getByTestId("membership-denied")).toHaveCount(0);
});
test("membership denial can import a different invited key", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
relayRequiresMembership: true,
relayRole: null,
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("membership-denied")).toBeVisible();
await page.getByTestId("membership-denied-change-key").click();
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
await page.getByTestId("membership-denied-nsec-input").fill(importedNsec);
await expect(
page.getByTestId("membership-denied-npub-preview"),
).toBeVisible();
await page.getByTestId("membership-denied-import-key").click();
// Alice already has a relay profile with a display name, so after the
// identity swap the onboarding gate auto-completes.
// The identity swap must tear down the old relay socket through the native
// disconnect command before the replacement identity connects.
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMANDS__?: string[];
}
).__BUZZ_E2E_COMMANDS__?.includes("plugin:websocket|disconnect") ??
false,
),
)
.toBe(true);
await expect
.poll(() =>
page.evaluate((storageKey) => {
const rawIdentity = window.localStorage.getItem(storageKey);
const identity = rawIdentity
? (JSON.parse(rawIdentity) as { pubkey?: string })
: null;
return identity?.pubkey ?? null;
}, E2E_IDENTITY_OVERRIDE_STORAGE_KEY),
)
.toBe(TEST_IDENTITIES.alice.pubkey);
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectHomeView(page);
});
test("onboarding relay reconnect — click shows Connected then auto-dismisses", async ({
page,
}) => {
// Produce the relay reconnect card via a relay-unreachable profile save error.
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
profileUpdateError: "relay unreachable: could not connect to relay",
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
const card = page.getByTestId("onboarding-relay-reconnect-card");
await expect(card).toBeVisible();
await expect(card).toContainText("Can't reach the relay");
// Drive degraded before clicking so the card is in the expected error state.
await setRelayConnectionState(page, "disconnected");
// Click the reconnect button. The controller attempts reconnect; the mock
// relay reconnects successfully (fast-path or via the state seam). Either
// path should result in the card transitioning to Connected and then
// auto-dismissing.
await page.getByTestId("onboarding-reconnect-relay").click();
// Drive connected to ensure the controller's connection-state path fires
// and the component's hadActiveReconnectRef guard is satisfied.
await setRelayConnectionState(page, "connected");
await expect(card).toContainText("Connected", { timeout: 5_000 });
await expect(card).not.toContainText("Can't reach the relay");
// Auto-dismiss fires after ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS
// (2500ms). Allow generous headroom for CI.
await expect(card).toBeHidden({ timeout: 10_000 });
});
test("onboarding relay reconnect — connected without a prior click does not show Connected", async ({
page,
}) => {
// Tests the hadActiveReconnectRef guard: if the relay transitions to
// "connected" without the user having clicked the reconnect button, the card
// must NOT show Connected. This guards against spurious success on initial
// connection or background recovery with no user action.
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
profileUpdateError: "relay unreachable: could not connect to relay",
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
const card = page.getByTestId("onboarding-relay-reconnect-card");
await expect(card).toBeVisible();
// Drive to disconnected state (no reconnect click has happened).
await setRelayConnectionState(page, "disconnected");
// Drive to connected WITHOUT clicking — this would happen on a spontaneous
// background recovery. The hadActiveReconnectRef guard must block markSuccess().
await setRelayConnectionState(page, "connected");
// Wait long enough for any spurious markSuccess() to fire, then assert the
// card stayed in the error state.
await page.waitForTimeout(500);
await expect(card).toBeVisible();
await expect(card).toContainText("Can't reach the relay");
await expect(card).not.toContainText("Connected");
});
test("membership denied shows all four affordances and change-community edits non-destructively", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
relayRequiresMembership: true,
relayRole: null,
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
// Fill the display name and advance — membership check triggers denial.
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
// Membership-denied screen renders with all four affordances.
const denied = page.getByTestId("membership-denied");
await expect(denied).toBeVisible();
await expect(denied.getByRole("button", { name: "Try again" })).toBeVisible();
await expect(denied.getByRole("button", { name: "Back" })).toBeVisible();
await expect(
denied.getByRole("button", { name: "Change community" }),
).toBeVisible();
await expect(page.getByTestId("membership-denied-change-key")).toBeVisible();
await expect(
page.getByTestId("membership-denied-redeem-invite"),
).toBeVisible();
// Click "Change community" → the overlay opens.
await denied.getByRole("button", { name: "Change community" }).click();
const overlay = page.getByTestId("community-change-overlay");
await expect(overlay).toBeVisible();
// Change the relay URL to a new one. The probe will time out for a fake URL
// so we wait for the "Use anyway" button.
await overlay
.locator("#community-edit-url")
.fill("wss://new-relay.example.com");
await overlay.getByRole("button", { name: "Save changes" }).click();
// The fields are frozen while the probe is pending, so the saved URL and
// any warning cannot get out of sync with a subsequent edit.
await expect(overlay.locator("#community-edit-url")).toBeDisabled();
await expect(overlay.locator("#community-edit-name")).toBeDisabled();
await expect(
overlay.getByRole("button", { name: "Use anyway" }),
).toBeVisible();
await overlay.getByRole("button", { name: "Use anyway" }).click();
// The community update triggers a remount (reinitKey bump). The persisted
// community should now point to the new relay URL.
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem("buzz-communities");
const communities = raw
? (JSON.parse(raw) as Array<{ relayUrl?: string }>)
: [];
return communities[0]?.relayUrl ?? null;
}),
)
.toBe("wss://new-relay.example.com");
// Identity was NOT wiped — the override storage key is still intact.
await expect
.poll(() =>
page.evaluate((storageKey) => {
return window.localStorage.getItem(storageKey) !== null;
}, E2E_IDENTITY_OVERRIDE_STORAGE_KEY),
)
.toBe(true);
});
test("cancel from profile Back preserves drafts and denied Back returns to interrupted page", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
relayRequiresMembership: true,
relayRole: null,
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
// --- Profile step: Back opens community-change overlay ---
const nameInput = page.getByTestId("onboarding-display-name");
await nameInput.fill("Morty QA");
await page.getByTestId("onboarding-back").click();
// The community change overlay should open.
const overlay = page.getByTestId("community-change-overlay");
await expect(overlay).toBeVisible();
// Cancel the overlay — profile should still be visible with the name intact.
await overlay.getByRole("button", { name: "Cancel" }).click();
await expect(overlay).toHaveCount(0);
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await expect(nameInput).toHaveValue("Morty QA");
// --- Profile → membership denied → Back returns to profile ---
// Advance triggers membership check → denied (relayRole is null).
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("membership-denied")).toBeVisible();
// Press Back on the denied screen — should return to the profile page
// (deniedFromPage = "profile") with the name draft intact.
await page
.getByTestId("membership-denied")
.getByRole("button", { name: "Back" })
.click();
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await expect(nameInput).toHaveValue("Morty QA");
});
test("denied on relay A then paste relay B invite URL switches community to B", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
relayRequiresMembership: true,
relayRole: null,
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
// Record the initial relay URL (relay A).
const initialRelayUrl = await page.evaluate(() => {
const raw = window.localStorage.getItem("buzz-communities");
const communities = raw
? (JSON.parse(raw) as Array<{ relayUrl?: string }>)
: [];
return communities[0]?.relayUrl ?? null;
});
expect(initialRelayUrl).not.toBeNull();
// Fill name, advance → denied on relay A.
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("membership-denied")).toBeVisible();
// Intercept the claimInvite POST to relay B so it succeeds.
const relayBUrl = "wss://relay-b.example.com";
const relayBHttpUrl = "https://relay-b.example.com";
const policyReceipt = "relay-signed-policy-receipt";
await page.route(`${relayBHttpUrl}/api/join-policy`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
policy: {
terms_markdown: "# Terms",
privacy_markdown: "# Privacy",
age_attestation_required: true,
version: "policy-v1",
},
}),
});
});
await page.route(
`${relayBHttpUrl}/api/invites/accept-policy`,
async (route) => {
expect(route.request().postDataJSON()).toEqual({
code: "test-invite-code",
policy_version: "policy-v1",
age_confirmed: true,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ receipt: policyReceipt }),
});
},
);
await page.route(`${relayBHttpUrl}/api/invites/claim`, async (route) => {
expect(route.request().postDataJSON()).toMatchObject({
code: "test-invite-code",
policy_receipt: policyReceipt,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
status: "joined",
community_id: "mock-community",
host: "relay-b.example.com",
role: "member",
}),
});
});
// Click "Have an invite?" and enter an HTTPS invite URL for relay B.
await page.getByTestId("membership-denied-redeem-invite").click();
await page
.getByTestId("invite-redeem-input")
.fill(`${relayBHttpUrl}/invite/test-invite-code`);
await page.getByTestId("invite-redeem-submit").click();
await expect(page.getByText("I am 18 years of age or older.")).toBeVisible();
await page.getByLabel("I am 18 years of age or older.").check();
await page
.getByLabel("I agree to the Buzz Terms of Service and Privacy Policy.")
.check();
await page.getByTestId("invite-redeem-submit").click();
// After successful claim, relay B is added and becomes active; relay A remains
// in the community list for future switching.
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem("buzz-communities");
const activeCommunityId = window.localStorage.getItem(
"buzz-active-community-id",
);
const communities = raw
? (JSON.parse(raw) as Array<{ id?: string; relayUrl?: string }>)
: [];
return communities.find(({ id }) => id === activeCommunityId)?.relayUrl;
}),
)
.toBe(relayBUrl);
});