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
Signed-off-by: cls_宁波本机 <908705107@qq.com>
3789 lines
127 KiB
TypeScript
3789 lines
127 KiB
TypeScript
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: "I’m a member or admin" }),
|
||
).toBeVisible();
|
||
|
||
await page.getByRole("button", { name: "I’m 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 couldn’t 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 couldn’t 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 couldn’t 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 couldn’t 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);
|
||
});
|