Files
buzz/desktop/tests/e2e/onboarding.spec.ts

1945 lines
65 KiB
TypeScript

import { hexToBytes } from "@noble/hashes/utils.js";
import { expect, test, type Locator, type Page } from "@playwright/test";
import { npubEncode, nsecEncode } from "nostr-tools/nip19";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
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,
) {
if (state !== "connected") {
await page.waitForFunction(() => {
const win = window as Window & {
__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => string;
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
};
return (
typeof win.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function" &&
typeof win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__ === "function" &&
win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__() === "connected"
);
});
} else {
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.");
}
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 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 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 expectCommunityBranchFramePosition(page: Page, frame: Locator) {
const box = await frame.boundingBox();
const viewport = page.viewportSize();
if (!box || !viewport) {
throw new Error("Could not measure community branch frame position");
}
const chromeOffset = 106;
const footerOffset = 144;
const frameCenterY = box.y + box.height / 2;
const usableLaneCenterY =
chromeOffset + (viewport.height - chromeOffset - footerOffset) / 2;
expect(frameCenterY).toBeGreaterThan(usableLaneCenterY);
expect(box.y + box.height).toBeLessThan(viewport.height - footerOffset);
}
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();
if (!bannerBox || !personaMentionBox || !composerBox) {
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);
expect(bannerBox.y + bannerBox.height).toBeGreaterThan(composerBox.y);
const radii = await banner.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
bottomLeft: styles.borderBottomLeftRadius,
bottomRight: styles.borderBottomRightRadius,
topLeft: styles.borderTopLeftRadius,
topRight: styles.borderTopRightRadius,
};
});
expect(radii.topLeft).toBe(radii.topRight);
expect(radii.bottomLeft).toBe("0px");
expect(radii.bottomRight).toBe("0px");
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);
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");
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();
}
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 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("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: "Enter a 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("first-community choices expose npub and invite input", 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",
skipOnboardingSeed: true,
skipCommunitySeed: true,
});
await page.route(
"https://default.example.com/api/join-policy",
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: "{}",
});
},
);
await page.goto("/");
await expect(
page.getByRole("button", { name: "Add me to a community" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "I have an invite link" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "I want to create a community" }),
).toBeVisible();
await page
.getByRole("button", { name: "I want to create a community" })
.click();
await expect
.poll(() =>
page.evaluate(() => {
const log = (
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: unknown;
}>;
}
).__BUZZ_E2E_COMMAND_LOG__;
return log?.find((entry) => entry.command === "plugin:opener|open_url")
?.payload;
}),
)
.toMatchObject({ url: "https://buzz.xyz" });
await page.getByRole("button", { name: "Add me to a community" }).click();
await expect(page.getByTestId("welcome-join-npub")).toHaveText(
npubEncode(BLANK_TYLER_IDENTITY.pubkey),
);
const joinKeyFrame = page.getByTestId("welcome-join-npub-frame");
const joinNpub = page.getByTestId("welcome-join-npub");
await expect(joinKeyFrame).toBeVisible();
await expect(joinNpub).toBeVisible();
await expectCommunityBranchFramePosition(page, joinKeyFrame);
const joinKeyFrameBox = await joinKeyFrame.boundingBox();
expect(joinKeyFrameBox?.width).toBeGreaterThan(700);
const joinKeyFrameStyles = await joinKeyFrame.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backgroundColor: styles.backgroundColor,
borderRadius: styles.borderRadius,
};
});
expect(joinKeyFrameStyles.backgroundColor).toMatch(/(0\.5\)|\/ 0\.5\))/);
expect(joinKeyFrameStyles.borderRadius).toBe("12px");
await expect
.poll(() =>
joinNpub.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
color: styles.color,
fontFamily: styles.fontFamily,
fontSize: styles.fontSize,
};
}),
)
.toMatchObject({
color: "rgb(113, 113, 6)",
fontSize: "36px",
});
expect(
(
await joinNpub.evaluate((element) =>
window.getComputedStyle(element).fontFamily.toLowerCase(),
)
).includes("mono"),
).toBe(true);
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "I have an invite link" }).click();
await expect(
page.getByRole("heading", { name: "Enter your invite link" }),
).toBeVisible();
const inviteInputFrame = page.getByTestId("invite-redeem-input-frame");
const inviteInput = page.getByTestId("invite-redeem-input");
await expect(inviteInputFrame).toBeVisible();
await expect(inviteInput).toBeVisible();
await expectCommunityBranchFramePosition(page, inviteInputFrame);
const inviteInputFrameBox = await inviteInputFrame.boundingBox();
expect(inviteInputFrameBox?.width).toBeGreaterThan(700);
const inviteInputFrameStyles = await inviteInputFrame.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backgroundColor: styles.backgroundColor,
borderRadius: styles.borderRadius,
};
});
expect(inviteInputFrameStyles.backgroundColor).toMatch(/(0\.5\)|\/ 0\.5\))/);
expect(inviteInputFrameStyles.borderRadius).toBe("12px");
await expect
.poll(() =>
inviteInput.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
color: styles.color,
fontFamily: styles.fontFamily,
fontSize: styles.fontSize,
};
}),
)
.toMatchObject({
color: "rgb(113, 113, 6)",
fontSize: "36px",
});
expect(
(
await inviteInput.evaluate((element) =>
window.getComputedStyle(element).fontFamily.toLowerCase(),
)
).includes("mono"),
).toBe(true);
await expect(page.getByTestId("invite-redeem-submit")).toHaveText("Next");
await expect(page.getByTestId("invite-redeem-submit")).toBeDisabled();
});
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, undefined, {
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: "Add me to a community" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "I have an invite link" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "I want to 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();
});
test("first-community profile step uses onboarding 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: "wss://default.example.com",
communityName: "Default",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{
pubkey: BLANK_TYLER_IDENTITY.pubkey,
transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
},
);
await installMockBridge(page, undefined, {
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
skipCommunitySeed: 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 profileMainBox = await profileMain.boundingBox();
const viewport = page.viewportSize();
if (!profileMainBox || !viewport) {
throw new Error("Could not measure community profile body position");
}
const chromeOffset = 106;
const footerOffset = 144;
const profileMainCenterY = profileMainBox.y + profileMainBox.height / 2;
const centeredInUsableLaneY =
chromeOffset + (viewport.height - chromeOffset - footerOffset) / 2;
expect(Math.abs(profileMainCenterY - centeredInUsableLaneY)).toBeLessThan(32);
const keyFrame = page.getByTestId("community-profile-key-frame");
const nameKey = page.getByTestId("community-profile-name-key");
await expect(keyFrame).toBeVisible();
await expect(nameKey).toBeVisible();
const keyFrameBox = await keyFrame.boundingBox();
expect(keyFrameBox?.width).toBeGreaterThan(700);
const keyFrameStyles = await keyFrame.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backgroundColor: styles.backgroundColor,
borderRadius: styles.borderRadius,
};
});
expect(keyFrameStyles.backgroundColor).toMatch(/(0\.5\)|\/ 0\.5\))/);
expect(keyFrameStyles.borderRadius).toBe("12px");
await expect
.poll(() =>
nameKey.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
color: styles.color,
fontFamily: styles.fontFamily,
fontSize: styles.fontSize,
};
}),
)
.toMatchObject({
color: "rgb(113, 113, 6)",
fontSize: "36px",
});
expect(
(
await nameKey.evaluate((element) =>
window.getComputedStyle(element).fontFamily.toLowerCase(),
)
).includes("mono"),
).toBe(true);
await expect(page.getByTestId("community-profile-next")).toHaveText("Next");
await expect(page.getByTestId("community-profile-next")).toBeDisabled();
await expect(page.getByTestId("community-profile-back")).toHaveText("Back");
await page.getByTestId("community-profile-back").click();
await expect(
page.getByRole("button", { name: "Add me to a community" }),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
),
)
.toBeNull();
});
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);
});
function retryToast(page: Page, title: string) {
return page
.locator("[data-sonner-toast][data-removed='false']")
.filter({ hasText: title });
}
async function retryToastAction(
page: Page,
{ command, title }: { command: string; title: string },
) {
const activeToast = retryToast(page, title);
await expect(
activeToast.getByRole("button", { name: "Retry" }),
).toBeVisible();
const before = await commandCount(page, command);
await activeToast
.getByRole("button", { name: "Retry" })
.dispatchEvent("click");
await expect.poll(() => commandCount(page, command)).toBeGreaterThan(before);
}
async function commandCount(page: Page, command: string) {
return page.evaluate(
(target) =>
window.__BUZZ_E2E_COMMANDS__?.filter((entry) => entry === target)
.length ?? 0,
command,
);
}
test("failed starter channel retries recreate actionable toasts", async ({
page,
}) => {
const starterError = "Mock starter channel setup failed.";
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
ensureStarterChannelsErrors: [starterError, starterError, starterError],
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await completeProfileOnboarding(page);
await expectPrivateWelcomeLanding(page);
const title = "Couldn't set up starter channels";
const activeToast = retryToast(page, title);
await expect(activeToast).toContainText(starterError);
await retryToastAction(page, {
command: "ensure_starter_channels",
title,
});
await expect(activeToast).toContainText(starterError);
await expect(
activeToast.getByRole("button", { name: "Retry" }),
).toBeVisible();
});
test("successful starter channel retry clears its actionable 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);
const title = "Couldn't set up starter channels";
await expect(retryToast(page, title)).toContainText(starterError);
await retryToastAction(page, {
command: "ensure_starter_channels",
title,
});
await expect(retryToast(page, title)).toHaveCount(0);
await expectWelcomeView(page);
await expectStarterChannels(page);
});
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);
await expect(page.getByTestId("message-timeline")).toContainText(
"Hi, 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, 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 can import an existing key when the community is already set up", async ({
page,
}) => {
// Community exists (default seed), but this identity has no profile yet,
// so the app lands on the onboarding name step — Tyler's moved-laptop /
// fresh-dev-instance case.
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await expect(page.getByTestId("onboarding-display-name")).toHaveValue("");
await page.getByTestId("onboarding-import-key").click();
await expect(
page.getByRole("heading", { name: "Use your existing key" }),
).toBeVisible();
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
await expect(page.getByTestId("nostr-import-npub-preview")).toBeVisible();
await page.getByTestId("nostr-import-submit").click();
// Identity swap remounts the flow; alice already has a relay profile with
// a display name, so onboarding auto-completes and lands in the app.
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectHomeView(page);
});
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("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("membership denial can import a different invited key", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
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. There is no
// `plugin:websocket|disconnect` command in tauri-plugin-websocket — closing
// is a Close frame sent through `plugin:websocket|send`.
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{
command: string;
payload: unknown;
}>;
}
).__BUZZ_E2E_COMMAND_PAYLOADS__?.some(
(entry) =>
entry.command === "plugin:websocket|send" &&
(entry.payload as { message?: { type?: string } })?.message
?.type === "Close",
) ?? 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,
{
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,
{
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,
{
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);
});