mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): confirm invites with an Opening-invite loading gate before machine onboarding (#1950)
Signed-off-by: Matt Toohey <contact@matttoohey.com> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -93,6 +93,7 @@ export default defineConfig({
|
||||
"**/buzz-theme-screenshots.spec.ts",
|
||||
"**/channel-sort.spec.ts",
|
||||
"**/identity-lost.spec.ts",
|
||||
"**/deep-link-invite.spec.ts",
|
||||
"**/global-agent-config-screenshots.spec.ts",
|
||||
"**/doctor-states.spec.ts",
|
||||
"**/onboarding-avatar-skip.spec.ts",
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useCommunityOnboarding } from "@/features/onboarding/communityOnboardin
|
||||
import { CommunityOnboardingFlow } from "@/features/onboarding/ui/CommunityOnboardingFlow";
|
||||
import { MachineOnboardingFlow } from "@/features/onboarding/ui/MachineOnboardingFlow";
|
||||
import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
|
||||
import { PendingInviteGate } from "@/features/onboarding/ui/PendingInviteGate";
|
||||
import { KeyringLockedScreen } from "@/features/onboarding/ui/KeyringLockedScreen";
|
||||
import { RelaunchRequiredScreen } from "@/features/onboarding/ui/RelaunchRequiredScreen";
|
||||
import { ResetFailedScreen } from "@/features/onboarding/ui/ResetFailedScreen";
|
||||
@@ -255,14 +256,6 @@ function CommunityApp({ sharedIdentity }: { sharedIdentity: boolean }) {
|
||||
const communityOnboarding = useCommunityOnboarding();
|
||||
const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listenForDeepLinks({
|
||||
startCommunityOnboarding: communityOnboarding.start,
|
||||
});
|
||||
return () => {
|
||||
void unlisten.then((fn) => fn());
|
||||
};
|
||||
}, [communityOnboarding.start]);
|
||||
// Surface nest-related backend events (repos-dir errors, legacy migration)
|
||||
// as toasts. Mounted before useCommunityInit so the listeners are registered
|
||||
// ahead of the first apply_workspace call.
|
||||
@@ -389,11 +382,26 @@ function CommunityApp({ sharedIdentity }: { sharedIdentity: boolean }) {
|
||||
|
||||
function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
|
||||
const { activeCommunity } = useCommunities();
|
||||
const communityOnboarding = useCommunityOnboarding();
|
||||
const machine = useMachineOnboardingState({
|
||||
hasConfiguredCommunity: activeCommunity !== null,
|
||||
isSharedIdentity: sharedIdentity,
|
||||
});
|
||||
|
||||
// Deep links are captured here — above the machine-onboarding gate — not in
|
||||
// CommunityApp. The Rust side queues them; draining into the persisted
|
||||
// community-onboarding transaction immediately means an invite opened on a
|
||||
// fresh install is acknowledged on screen while the identity steps are
|
||||
// still pending, and survives a relaunch in between.
|
||||
useEffect(() => {
|
||||
const unlisten = listenForDeepLinks({
|
||||
startCommunityOnboarding: communityOnboarding.start,
|
||||
});
|
||||
return () => {
|
||||
void unlisten.then((fn) => fn());
|
||||
};
|
||||
}, [communityOnboarding.start]);
|
||||
|
||||
if (machine.stage === "reset-failed") return <ResetFailedScreen />;
|
||||
if (machine.stage === "keyring-locked") return <KeyringLockedScreen />;
|
||||
if (machine.stage === "relaunch-required") return <RelaunchRequiredScreen />;
|
||||
@@ -402,12 +410,24 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
|
||||
return <CommunityApp sharedIdentity={sharedIdentity} />;
|
||||
}
|
||||
|
||||
// A community deep link that arrived before machine onboarding finished is
|
||||
// persisted immediately and acknowledged here. Invite claiming waits until
|
||||
// setup completes so it is signed only by the user's final identity.
|
||||
const transaction = communityOnboarding.transaction;
|
||||
const isDeepLink =
|
||||
transaction?.source === "deep-link-join" ||
|
||||
transaction?.source === "deep-link-connect";
|
||||
const shouldAcknowledgeDeepLink = isDeepLink && !transaction.acknowledged;
|
||||
|
||||
return (
|
||||
<MachineOnboardingFlow
|
||||
complete={machine.complete}
|
||||
identityLost={machine.identityLost}
|
||||
queryClient={machine.queryClient}
|
||||
/>
|
||||
<>
|
||||
<MachineOnboardingFlow
|
||||
complete={machine.complete}
|
||||
identityLost={machine.identityLost}
|
||||
queryClient={machine.queryClient}
|
||||
/>
|
||||
{shouldAcknowledgeDeepLink ? <PendingInviteGate /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
markCommunityOnboardingComplete,
|
||||
startCommunityOnboarding,
|
||||
updateCommunityOnboardingTransaction,
|
||||
updateCurrentCommunityOnboardingTransaction,
|
||||
} from "./communityOnboarding.tsx";
|
||||
|
||||
function createMemoryStorage(initial = {}) {
|
||||
@@ -79,6 +80,53 @@ test("same-relay ingress resumes rather than replacing progress", () => {
|
||||
assert.equal(resumed.inviteCode, "new-code");
|
||||
});
|
||||
|
||||
test("stale asynchronous updates cannot mutate a replacement transaction", () => {
|
||||
const storage = createMemoryStorage();
|
||||
const original = startCommunityOnboarding(
|
||||
{
|
||||
source: "deep-link-join",
|
||||
relayUrl: "wss://relay.example",
|
||||
inviteCode: "invite-code",
|
||||
},
|
||||
storage,
|
||||
);
|
||||
const replacement = startCommunityOnboarding(
|
||||
{ source: "deep-link-connect", relayUrl: "wss://other.example" },
|
||||
storage,
|
||||
);
|
||||
|
||||
const result = updateCurrentCommunityOnboardingTransaction(
|
||||
replacement,
|
||||
{ stage: "connecting", error: "stale error" },
|
||||
original.id,
|
||||
storage,
|
||||
);
|
||||
|
||||
assert.equal(result, replacement);
|
||||
assert.equal(loadCommunityOnboardingTransaction(storage)?.id, replacement.id);
|
||||
assert.equal(loadCommunityOnboardingTransaction(storage)?.error, undefined);
|
||||
});
|
||||
|
||||
test("acknowledgment persists but resets when the same-relay link reopens", () => {
|
||||
const storage = createMemoryStorage();
|
||||
const transaction = startCommunityOnboarding(
|
||||
{ source: "deep-link-connect", relayUrl: "wss://relay.example" },
|
||||
storage,
|
||||
);
|
||||
assert.equal(transaction.stage, "connecting");
|
||||
updateCommunityOnboardingTransaction(
|
||||
transaction,
|
||||
{ acknowledged: true },
|
||||
storage,
|
||||
);
|
||||
assert.equal(loadCommunityOnboardingTransaction(storage)?.acknowledged, true);
|
||||
const reopened = startCommunityOnboarding(
|
||||
{ source: "deep-link-connect", relayUrl: "wss://relay.example" },
|
||||
storage,
|
||||
);
|
||||
assert.equal(reopened.acknowledged, undefined);
|
||||
});
|
||||
|
||||
test("malformed persisted state is ignored and can be cleared", () => {
|
||||
const storage = createMemoryStorage({
|
||||
"buzz-community-onboarding-transaction.v1": '{"stage":"profile"}',
|
||||
|
||||
@@ -33,8 +33,18 @@ export type CommunityOnboardingTransaction = {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
error?: string;
|
||||
// Deep links are persisted before machine onboarding completes. Set when
|
||||
// the user dismisses the acknowledgment so it stays dismissed on relaunch.
|
||||
acknowledged?: boolean;
|
||||
};
|
||||
|
||||
export type CommunityOnboardingTransactionPatch = Partial<
|
||||
Pick<
|
||||
CommunityOnboardingTransaction,
|
||||
"stage" | "communityId" | "communityName" | "error" | "acknowledged"
|
||||
>
|
||||
>;
|
||||
|
||||
export type StartCommunityOnboardingInput = {
|
||||
source: CommunityOnboardingSource;
|
||||
relayUrl: string;
|
||||
@@ -119,6 +129,9 @@ export function startCommunityOnboarding(
|
||||
reposDir: input.reposDir ?? existing.reposDir,
|
||||
updatedAt: now.toISOString(),
|
||||
error: undefined,
|
||||
// A freshly opened link deserves fresh feedback — re-present the gate
|
||||
// even if a previous link for this relay was already dismissed.
|
||||
acknowledged: undefined,
|
||||
};
|
||||
saveCommunityOnboardingTransaction(updated, storage);
|
||||
return updated;
|
||||
@@ -143,12 +156,7 @@ export function startCommunityOnboarding(
|
||||
|
||||
export function updateCommunityOnboardingTransaction(
|
||||
transaction: CommunityOnboardingTransaction,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
CommunityOnboardingTransaction,
|
||||
"stage" | "communityId" | "communityName" | "error"
|
||||
>
|
||||
>,
|
||||
patch: CommunityOnboardingTransactionPatch,
|
||||
storage: Storage = localStorage,
|
||||
now = new Date(),
|
||||
): CommunityOnboardingTransaction {
|
||||
@@ -157,6 +165,17 @@ export function updateCommunityOnboardingTransaction(
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function updateCurrentCommunityOnboardingTransaction(
|
||||
current: CommunityOnboardingTransaction | null,
|
||||
patch: CommunityOnboardingTransactionPatch,
|
||||
expectedId: string | undefined,
|
||||
storage: Storage = localStorage,
|
||||
now = new Date(),
|
||||
): CommunityOnboardingTransaction | null {
|
||||
if (!current || (expectedId && current.id !== expectedId)) return current;
|
||||
return updateCommunityOnboardingTransaction(current, patch, storage, now);
|
||||
}
|
||||
|
||||
export function markCommunityOnboardingComplete(
|
||||
pubkey: string,
|
||||
relayUrl: string,
|
||||
@@ -177,12 +196,8 @@ type CommunityOnboardingContextValue = {
|
||||
transaction: CommunityOnboardingTransaction | null;
|
||||
start: (input: StartCommunityOnboardingInput) => boolean;
|
||||
update: (
|
||||
patch: Partial<
|
||||
Pick<
|
||||
CommunityOnboardingTransaction,
|
||||
"stage" | "communityId" | "communityName" | "error"
|
||||
>
|
||||
>,
|
||||
patch: CommunityOnboardingTransactionPatch,
|
||||
expectedId?: string,
|
||||
) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
@@ -212,18 +227,9 @@ export function CommunityOnboardingProvider({
|
||||
[transaction],
|
||||
);
|
||||
const update = React.useCallback(
|
||||
(
|
||||
patch: Partial<
|
||||
Pick<
|
||||
CommunityOnboardingTransaction,
|
||||
"stage" | "communityId" | "communityName" | "error"
|
||||
>
|
||||
>,
|
||||
) => {
|
||||
(patch: CommunityOnboardingTransactionPatch, expectedId?: string) => {
|
||||
setTransaction((current) =>
|
||||
current
|
||||
? updateCommunityOnboardingTransaction(current, patch)
|
||||
: current,
|
||||
updateCurrentCommunityOnboardingTransaction(current, patch, expectedId),
|
||||
);
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -7,10 +7,9 @@ import {
|
||||
useCommunityOnboarding,
|
||||
} from "@/features/onboarding/communityOnboarding";
|
||||
import { initializeWelcomeChannel } from "@/features/onboarding/hooks";
|
||||
import { useClaimInvite } from "@/features/onboarding/useClaimInvite";
|
||||
import { AvatarUpload } from "@/features/profile/ui/AvatarUpload";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { claimInvite } from "@/shared/api/invites";
|
||||
import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
|
||||
import { updateProfile } from "@/shared/api/tauriProfiles";
|
||||
import { getIdentity } from "@/shared/api/tauriIdentity";
|
||||
import { listPersonas } from "@/shared/api/tauriPersonas";
|
||||
@@ -50,19 +49,11 @@ export function CommunityOnboardingFlow({
|
||||
.catch(() => setStarterPersonas([]));
|
||||
}, [transaction?.stage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (transaction?.stage !== "claiming" || isPending) return;
|
||||
setIsPending(true);
|
||||
void claimInvite(transaction.relayUrl, transaction.inviteCode ?? "")
|
||||
.then(() => update({ stage: "connecting", error: undefined }))
|
||||
.catch((error: unknown) => update({ error: inviteErrorMessage(error) }))
|
||||
.finally(() => setIsPending(false));
|
||||
}, [isPending, transaction, update]);
|
||||
useClaimInvite();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (transaction?.stage !== "connecting") return;
|
||||
onConnect();
|
||||
}, [onConnect, transaction]);
|
||||
if (transaction?.stage === "connecting") onConnect();
|
||||
}, [onConnect, transaction?.stage]);
|
||||
|
||||
const retryClaim = () => update({ stage: "claiming", error: undefined });
|
||||
const relayUrl = transaction?.relayUrl;
|
||||
|
||||
50
desktop/src/features/onboarding/ui/PendingInviteGate.tsx
Normal file
50
desktop/src/features/onboarding/ui/PendingInviteGate.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { FlappingBee } from "@/shared/ui/buzz-logo/FlappingBee";
|
||||
|
||||
/**
|
||||
* Acknowledge a community deep link received before machine onboarding is
|
||||
* complete. The transaction is already persisted; claiming and connecting
|
||||
* wait until setup finishes so only the user's final identity is admitted.
|
||||
*/
|
||||
export function PendingInviteGate() {
|
||||
const { transaction, update, clear } = useCommunityOnboarding();
|
||||
|
||||
if (!transaction) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="buzz-onboarding-neutral-theme fixed inset-0 z-50 flex items-center justify-center bg-background px-4 py-8 text-foreground"
|
||||
data-testid="pending-invite-gate"
|
||||
>
|
||||
<div className="flex w-full max-w-[440px] flex-col items-center text-center">
|
||||
<FlappingBee className="h-auto w-24" />
|
||||
<h1 className="mt-6 text-3xl font-semibold tracking-tight">
|
||||
Opening community link
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
You’ll connect to {transaction.communityName} once setup is finished.
|
||||
</p>
|
||||
<div className="mt-8 flex w-full flex-col gap-3">
|
||||
<Button
|
||||
className="h-10 w-full"
|
||||
data-testid="pending-invite-continue"
|
||||
onClick={() => update({ acknowledged: true })}
|
||||
type="button"
|
||||
>
|
||||
Continue setup
|
||||
</Button>
|
||||
<Button
|
||||
className="h-10 w-full"
|
||||
data-testid="pending-invite-cancel"
|
||||
onClick={clear}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
desktop/src/features/onboarding/useClaimInvite.ts
Normal file
35
desktop/src/features/onboarding/useClaimInvite.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
|
||||
import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
|
||||
import { claimInvite } from "@/shared/api/invites";
|
||||
|
||||
/**
|
||||
* Drive the `claiming` stage after machine onboarding completes: claim the
|
||||
* invite with the user's final identity, then advance to `connecting`.
|
||||
* Completion is fenced by transaction ID so cancelling or replacing the
|
||||
* transaction while the request is pending cannot mutate the replacement.
|
||||
*
|
||||
* The error guard keeps a failed claim parked on the caller's Retry
|
||||
* affordance — without it the effect refires on the error-bearing transaction
|
||||
* and re-claims in a loop.
|
||||
*/
|
||||
export function useClaimInvite() {
|
||||
const { transaction, update } = useCommunityOnboarding();
|
||||
const [isPending, setIsPending] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (transaction?.stage !== "claiming" || transaction.error || isPending) {
|
||||
return;
|
||||
}
|
||||
setIsPending(true);
|
||||
void claimInvite(transaction.relayUrl, transaction.inviteCode ?? "")
|
||||
.then(() => {
|
||||
update({ stage: "connecting", error: undefined }, transaction.id);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
update({ error: inviteErrorMessage(error) }, transaction.id),
|
||||
)
|
||||
.finally(() => setIsPending(false));
|
||||
}, [isPending, transaction, update]);
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import { getRelayHttpUrl, signRelayEvent } from "@/shared/api/tauri";
|
||||
|
||||
const NIP98_KIND = 27235;
|
||||
|
||||
// Bound invite requests so an unreachable relay surfaces as an error in the
|
||||
// invite-loading UI within seconds instead of hanging for the OS-level
|
||||
// connect timeout (a minute or more on macOS).
|
||||
const INVITE_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
export type MintedInvite = {
|
||||
code: string;
|
||||
expiresAt: number;
|
||||
@@ -71,6 +76,7 @@ async function invitePost<T>(
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(INVITE_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const json = (await response.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
|
||||
@@ -234,6 +234,17 @@ type E2eConfig = {
|
||||
// Event IDs that `get_event` should report as definitively not found.
|
||||
// Causes `useDraftRootStatus` to classify as `deleted`.
|
||||
deletedEventIds?: string[];
|
||||
// Pending community deep links (buzz://join / buzz://connect) seeded into
|
||||
// the mocked Rust-side queue. Mirrors the real queue's semantics:
|
||||
// `take_pending_community_deep_link` peeks the head and
|
||||
// `acknowledge_pending_community_deep_link` removes by id. Drives the
|
||||
// pending-invite gate and deep-link drain path in tests.
|
||||
pendingCommunityDeepLinks?: Array<{
|
||||
id: string;
|
||||
kind: "connect" | "join";
|
||||
relayUrl: string;
|
||||
code?: string | null;
|
||||
}>;
|
||||
// When true, `get_identity` returns `lost: true` until `persist_current_identity`
|
||||
// or `import_identity` is called. Drives the identity-lost recovery UX in tests.
|
||||
identityLost?: boolean;
|
||||
@@ -3536,6 +3547,20 @@ function resetMockUserStatuses() {
|
||||
mockUserStatuses.length = 0;
|
||||
}
|
||||
|
||||
// Mocked Rust-side pending deep-link queue (see desktop/src-tauri/src/deep_link.rs).
|
||||
let mockPendingCommunityDeepLinks: Array<{
|
||||
id: string;
|
||||
kind: string;
|
||||
relayUrl: string;
|
||||
code: string | null;
|
||||
}> = [];
|
||||
|
||||
function resetMockPendingCommunityDeepLinks(config: E2eConfig | null) {
|
||||
mockPendingCommunityDeepLinks = (
|
||||
config?.mock?.pendingCommunityDeepLinks ?? []
|
||||
).map((pending) => ({ ...pending, code: pending.code ?? null }));
|
||||
}
|
||||
|
||||
function recordMockUserStatus(event: RelayEvent) {
|
||||
const dTag = event.tags.find((tag) => tag[0] === "d")?.[1];
|
||||
if (dTag) {
|
||||
@@ -8215,6 +8240,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
resetMockWorkflows();
|
||||
resetMockMesh();
|
||||
resetMockUserStatuses();
|
||||
resetMockPendingCommunityDeepLinks(config);
|
||||
mockWebsocketSendMutexWedged = false;
|
||||
mockWindows("main");
|
||||
window.__BUZZ_E2E_COMMANDS__ = [];
|
||||
@@ -8807,6 +8833,20 @@ export function maybeInstallE2eTauriMocks() {
|
||||
activeWorkspaceId: null,
|
||||
onboardingCompletions: [],
|
||||
};
|
||||
case "take_pending_community_deep_link":
|
||||
// Mirrors the Rust queue: peek the head; acknowledge removes it.
|
||||
return mockPendingCommunityDeepLinks[0] ?? null;
|
||||
case "acknowledge_pending_community_deep_link": {
|
||||
const { id } = payload as { id: string };
|
||||
const index = mockPendingCommunityDeepLinks.findIndex(
|
||||
(pending) => pending.id === id,
|
||||
);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
mockPendingCommunityDeepLinks.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
case "get_relay_http_url":
|
||||
return getRelayHttpUrl(activeConfig);
|
||||
case "discover_acp_providers":
|
||||
|
||||
139
desktop/tests/e2e/deep-link-invite.spec.ts
Normal file
139
desktop/tests/e2e/deep-link-invite.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
// 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 TRANSACTION_STORAGE_KEY = "buzz-community-onboarding-transaction.v1";
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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("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();
|
||||
});
|
||||
@@ -290,6 +290,17 @@ type MockBridgeOptions = {
|
||||
* invoked. Drives the keyring-locked screen in tests.
|
||||
*/
|
||||
identityLocked?: boolean;
|
||||
/**
|
||||
* Pending community deep links (buzz://join / buzz://connect) seeded into
|
||||
* the mocked Rust-side queue. The frontend drains these on boot into a
|
||||
* community-onboarding transaction — drives the pending-invite gate.
|
||||
*/
|
||||
pendingCommunityDeepLinks?: Array<{
|
||||
id: string;
|
||||
kind: "connect" | "join";
|
||||
relayUrl: string;
|
||||
code?: string | null;
|
||||
}>;
|
||||
/**
|
||||
* Global agent config returned by `get_global_agent_config`. Defaults to
|
||||
* an empty config (no provider, model, or env vars) if not specified.
|
||||
|
||||
Reference in New Issue
Block a user