Files
buzz/desktop/tests/e2e/deep-link-invite.spec.ts

474 lines
15 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { seedActiveIdentity } from "../helpers/onboarding";
// Community deep links that arrive before machine onboarding complete are
// drained from Rust into a persisted transaction and acknowledged immediately.
// Invite claiming waits until setup finishes and the final identity is known.
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
const COMMUNITY_ONBOARDING_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
const TRANSACTION_STORAGE_KEY = "buzz-community-onboarding-transaction.v1";
const COMMUNITY_RELAY_URL = "wss://hive.example.com";
const PENDING_JOIN_LINK = {
id: "dl-join-1",
kind: "join" as const,
relayUrl: "wss://hive.example.com",
code: "abc.def",
};
const PENDING_CONNECT_LINK = {
id: "dl-connect-1",
kind: "connect" as const,
relayUrl: "wss://hive.example.com",
code: null,
};
const PENDING_ADD_COMMUNITY_LINK = {
id: "dl-add-community-1",
kind: "add-community" as const,
relayUrl: "wss://acme.communities.buzz.xyz",
code: null,
name: "Acme Team",
};
const SECOND_PENDING_ADD_COMMUNITY_LINK = {
id: "dl-add-community-2",
kind: "add-community" as const,
relayUrl: "wss://beta.communities.buzz.xyz",
code: null,
name: "Beta Team",
};
test("join deep link is acknowledged without claiming before setup", async ({
page,
}) => {
let claimCalls = 0;
await page.route("**/api/invites/claim", async (route) => {
claimCalls++;
await route.abort();
});
await installMockBridge(
page,
{ pendingCommunityDeepLinks: [PENDING_JOIN_LINK] },
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
const gate = page.getByTestId("pending-invite-gate");
await expect(gate).toBeVisible();
await expect(
page.getByRole("heading", { name: "Opening community link" }),
).toBeVisible();
await page.getByTestId("pending-invite-continue").click();
await expect(gate).toHaveCount(0);
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
expect(claimCalls).toBe(0);
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
TRANSACTION_STORAGE_KEY,
),
)
.toContain('"stage":"claiming"');
});
test("connect deep link shows a static acknowledgment during setup", async ({
page,
}) => {
// No invite code means nothing to confirm against the relay — the gate
// acknowledges the link and waits for the user instead of auto-advancing.
await installMockBridge(
page,
{ pendingCommunityDeepLinks: [PENDING_CONNECT_LINK] },
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
const gate = page.getByTestId("pending-invite-gate");
await expect(gate).toBeVisible();
await expect(
page.getByRole("heading", { name: "Opening community link" }),
).toBeVisible();
await expect(gate).toContainText("hive");
// Continue setup dismisses the gate but keeps the transaction: the
// connect resumes in CommunityOnboardingFlow after machine setup.
await page.getByTestId("pending-invite-continue").click();
await expect(gate).toHaveCount(0);
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
TRANSACTION_STORAGE_KEY,
),
)
.toContain('"acknowledged":true');
});
test("add-community deep link starts onboarding when no community is configured", async ({
page,
}) => {
// profileReadError forces the fallback path (error → profile step), so the
// test asserts pre-existing-profile behavior without the default mock
// identity's has_profile_event:true triggering the skip.
await installMockBridge(
page,
{
pendingCommunityDeepLinks: [PENDING_ADD_COMMUNITY_LINK],
profileReadError: "no-kind-0",
},
{ skipCommunitySeed: true },
);
await page.goto("/");
await expect(page.getByTestId("community-onboarding-flow")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
TRANSACTION_STORAGE_KEY,
),
)
.toContain('"source":"add-community"');
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
TRANSACTION_STORAGE_KEY,
),
)
.toContain('"communityName":"Acme Team"');
});
test("add-community deep link skips profile step when identity has an existing kind:0 profile", async ({
page,
}) => {
// The default mock identity (deadbeef...) is pre-seeded with
// has_profile_event:true. The skip should fire on connecting → clear the
// transaction entirely, never showing the profile step.
await installMockBridge(
page,
{ pendingCommunityDeepLinks: [PENDING_ADD_COMMUNITY_LINK] },
{ skipCommunitySeed: true },
);
await page.goto("/");
// Onboarding flow must disappear — the skip cleared the transaction.
await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0);
// handleCommunityOnboardingConnect already added the community when the
// transaction reached "connecting", so the app lands in the full UI.
await expect(page.getByTestId("sidebar-profile-avatar-button")).toBeVisible();
});
test("add-community deep link opens one editable prefill and acknowledges the queue", async ({
page,
}) => {
await installMockBridge(
page,
{ pendingCommunityDeepLinks: [PENDING_ADD_COMMUNITY_LINK] },
{ seedPreviewFeatures: true },
);
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Join an existing community" }),
).toBeVisible();
const communityInput = page.getByLabel("Community URL or invite link");
await expect(communityInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.relayUrl);
await expect(page.getByLabel("Name")).toHaveCount(0);
await page.getByRole("button", { name: "Close" }).click();
await expect(
page.getByRole("heading", { name: "Join an existing community" }),
).toHaveCount(0);
await page.getByTestId("sidebar-profile-avatar-button").click();
await page.getByTestId("community-switcher").click();
await page.getByRole("menuitem", { name: "Add a community" }).click();
await page.getByTestId("add-community-join").click();
await expect(communityInput).toHaveValue("");
const acknowledgements = await page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
(entry) => entry.command === "acknowledge_pending_community_deep_link",
),
);
expect(acknowledgements).toEqual([
{
command: "acknowledge_pending_community_deep_link",
payload: { id: PENDING_ADD_COMMUNITY_LINK.id },
},
]);
});
test("queued add-community links open and acknowledge one at a time", async ({
page,
}) => {
await installMockBridge(
page,
{
pendingCommunityDeepLinks: [
PENDING_ADD_COMMUNITY_LINK,
SECOND_PENDING_ADD_COMMUNITY_LINK,
],
},
{ seedPreviewFeatures: true },
);
await page.goto("/");
const communityInput = page.getByLabel("Community URL or invite link");
await expect(communityInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.relayUrl);
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? [])
.filter(
(entry) =>
entry.command === "acknowledge_pending_community_deep_link",
)
.map((entry) => entry.payload),
),
)
.toEqual([{ id: PENDING_ADD_COMMUNITY_LINK.id }]);
await page.getByRole("button", { name: "Close" }).click();
await expect(communityInput).toHaveValue(
SECOND_PENDING_ADD_COMMUNITY_LINK.relayUrl,
);
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? [])
.filter(
(entry) =>
entry.command === "acknowledge_pending_community_deep_link",
)
.map((entry) => entry.payload),
),
)
.toEqual([
{ id: PENDING_ADD_COMMUNITY_LINK.id },
{ id: SECOND_PENDING_ADD_COMMUNITY_LINK.id },
]);
});
test("deleted public starter channels do not strand community onboarding", async ({
page,
}) => {
const starterError =
"starter channels created but metadata not yet available";
await seedActiveIdentity(page, TEST_IDENTITIES.tyler);
await page.addInitScript(
({ pubkey, relayUrl, storageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
storageKey,
JSON.stringify({
id: "txn-deleted-starters-1",
source: "deep-link-join",
stage: "team-intro",
relayUrl,
communityName: "hive",
communityId: "e2e-default-community",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{
pubkey: COMMUNITY_ONBOARDING_PUBKEY,
relayUrl: COMMUNITY_RELAY_URL,
storageKey: TRANSACTION_STORAGE_KEY,
},
);
await installMockBridge(
page,
{ ensureStarterChannelsErrors: [starterError] },
{ relayWsUrl: COMMUNITY_RELAY_URL, skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByRole("button", { name: "Take me to Buzz" }).click();
await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0);
await expect(page).toHaveURL(/#\/channels\/[^/]+$/);
await expect(page.getByTestId("chat-title")).toContainText("Welcome");
await expect(page.getByText(starterError)).toHaveCount(0);
expect(
await page.evaluate(
() =>
window.__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "ensure_starter_channels",
).length ?? 0,
),
).toBe(1);
});
test("required Welcome creation failure keeps community onboarding open", async ({
page,
}) => {
const welcomeError = "Channel creation is not permitted.";
await seedActiveIdentity(page, TEST_IDENTITIES.tyler);
await page.addInitScript(
({ pubkey, relayUrl, storageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
storageKey,
JSON.stringify({
id: "txn-welcome-failure-1",
source: "deep-link-join",
stage: "team-intro",
relayUrl,
communityName: "hive",
communityId: "e2e-default-community",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{
pubkey: COMMUNITY_ONBOARDING_PUBKEY,
relayUrl: COMMUNITY_RELAY_URL,
storageKey: TRANSACTION_STORAGE_KEY,
},
);
await installMockBridge(
page,
{ createChannelErrors: [welcomeError] },
{ relayWsUrl: COMMUNITY_RELAY_URL, skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByRole("button", { name: "Take me to Buzz" }).click();
await expect(page.getByTestId("community-onboarding-flow")).toBeVisible();
await expect(page.getByText(`${welcomeError} Try again.`)).toBeVisible();
await expect(
page.getByRole("button", { name: "Take me to Buzz" }),
).toBeEnabled();
await expect(page.getByTestId("chat-title")).toHaveCount(0);
});
test("persisted deep-link invite hands off to Joining after machine onboarding", async ({
page,
}) => {
// Deterministic claim failure (no real relay behind the mock bridge): the
// spec asserts the handoff reaches the "Joining …" claiming screen, not
// that the claim itself succeeds.
await page.route("**/api/invites/claim", (route) => route.abort());
await page.addInitScript(
({ pubkey, storageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
storageKey,
JSON.stringify({
id: "txn-deep-link-1",
source: "deep-link-join",
stage: "claiming",
relayUrl: "wss://hive.example.com",
inviteCode: "abc.def",
communityName: "hive",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{ pubkey: DEFAULT_MOCK_PUBKEY, storageKey: TRANSACTION_STORAGE_KEY },
);
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
// Machine onboarding is complete, so the transaction owns the screen.
await expect(page.getByTestId("community-onboarding-flow")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Joining hive" }),
).toBeVisible();
await expect(page.getByTestId("pending-invite-gate")).toHaveCount(0);
// The claim was attempted and its failure surfaced with a Retry.
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
});
test("receipt-less app handoff collects consent before claiming", async ({
page,
}) => {
const policy = {
version: "policy-v1",
terms_markdown: "Terms",
privacy_markdown: "Privacy",
age_attestation_required: true,
};
let claims = 0;
await page.route("**/api/join-policy", (route) =>
route.fulfill({ json: { policy } }),
);
await page.route("**/api/invites/accept-policy", (route) => {
expect(route.request().postDataJSON()).toEqual({
code: PENDING_JOIN_LINK.code,
policy_version: policy.version,
age_confirmed: true,
});
return route.fulfill({ json: { receipt: "native-consent" } });
});
await page.route("**/api/invites/claim", (route) => {
claims++;
expect(route.request().postDataJSON()).toEqual({
code: PENDING_JOIN_LINK.code,
policy_receipt: "native-consent",
});
return route.fulfill({
json: {
status: "joined",
community_id: "hive",
host: "hive.example.com",
role: "member",
},
});
});
await installMockBridge(page, {
pendingCommunityDeepLinks: [PENDING_JOIN_LINK],
});
await page.goto("/");
const age = page.getByRole("checkbox", {
name: "I am 18 years of age or older.",
});
await expect(age).toBeVisible();
const accept = page.getByRole("button", { name: "Accept and redeem invite" });
await expect(accept).toBeDisabled();
expect(claims).toBe(0);
await age.check();
await page
.getByRole("checkbox", {
name: /I agree to the Buzz Terms of Service and Privacy Policy/,
})
.check();
await accept.click();
await expect.poll(() => claims).toBe(1);
await expect(age).toHaveCount(0);
});