From 4ef08e23f7eb8887ca34e3e30e4af7d0885ec104 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 1 Oct 2026 16:29:11 -0400 Subject: [PATCH] fix(desktop): retain community init after onboarding Retire temporary onboarding state without resetting the applied community. Bind lifecycle regression coverage to the real Welcome-ready event and retirement callback with a paused, bounded browser clock. Signed-off-by: bb-expert <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz> --- desktop/src/app/App.tsx | 119 +++-- .../features/communities/useCommunityInit.ts | 7 + desktop/src/testing/e2eBridge.ts | 32 ++ desktop/tests/e2e/onboarding.spec.ts | 451 +++++++++++++++++- desktop/tests/helpers/bridge.ts | 2 + 5 files changed, 581 insertions(+), 30 deletions(-) diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 75c378ad23..01dda1c1cb 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -90,6 +90,24 @@ const BOOT_SPLASH_MIN_VISIBLE_MS = 1_200; const BOOT_SPLASH_FADE_MS = 200; const INITIAL_RENDER_READY_EVENT = "initial-render-ready"; +function incrementE2eAppReadyMounts() { + const e2eWindow = window as Window & { + __BUZZ_E2E__?: unknown; + __BUZZ_E2E_APP_READY_MOUNTS__?: number; + }; + if (!e2eWindow.__BUZZ_E2E__) return; + e2eWindow.__BUZZ_E2E_APP_READY_MOUNTS__ = + (e2eWindow.__BUZZ_E2E_APP_READY_MOUNTS__ ?? 0) + 1; +} + +function E2eAppReadyMountTelemetry() { + useEffect(() => { + incrementE2eAppReadyMounts(); + }, []); + + return null; +} + type BootSplashPhase = "holding" | "fading" | "done"; type CommunityOnboardingWorkOwner = { @@ -335,17 +353,34 @@ function AppReady({ isCommunitySwitch: boolean; }) { const onboarding = useAppOnboardingState(isSharedIdentity); + const isE2eBuild = import.meta.env.MODE === "e2e"; + const e2eTelemetry = isE2eBuild ? : null; if (onboarding.stage === "reset-failed") { - return ; + return ( + <> + {e2eTelemetry} + + + ); } if (onboarding.stage === "keyring-locked") { - return ; + return ( + <> + {e2eTelemetry} + + + ); } if (onboarding.stage === "relaunch-required") { - return ; + return ( + <> + {e2eTelemetry} + + + ); } if ( @@ -353,35 +388,46 @@ function AppReady({ (continueOnboarding && onboarding.stage === "blocking") ) { return ( - + <> + {e2eTelemetry} + + ); } if (onboarding.stage === "blocking") { - return isCommunitySwitch ? : ; + return ( + <> + {e2eTelemetry} + {isCommunitySwitch ? : } + + ); } return ( - - void router.navigate({ - to: "/settings", - search: { section: "profile" }, - }) - } - > - - - - + <> + {e2eTelemetry} + + void router.navigate({ + to: "/settings", + search: { section: "profile" }, + }) + } + > + + + + + ); } @@ -457,9 +503,28 @@ function CommunityApp({ const isContinuingOnboarding = continueOnboarding && !isCommunitySwitch; const transaction = communityOnboarding.transaction; + // The onboarding transaction is temporary control-plane state. Retiring it + // must not change the generation of an init that already applied this + // community, or useCommunityInit will tear down and apply the same + // workspace again. Update this during render so the clear() render already + // has the retired transaction's generation; keep the community id with it so + // another community can never inherit the value. + const lastCommunityInitAttemptRef = useRef<{ + communityId: string; + initAttempt: number; + } | null>(null); + const transactionOwnsActiveCommunity = + transaction !== null && transaction.communityId === activeCommunity?.id; + if (transactionOwnsActiveCommunity && activeCommunity) { + lastCommunityInitAttemptRef.current = { + communityId: activeCommunity.id, + initAttempt: transaction.initAttempt ?? 0, + }; + } const communityInitAttempt = - transaction && transaction.communityId === activeCommunity?.id - ? (transaction.initAttempt ?? 0) + activeCommunity && + lastCommunityInitAttemptRef.current?.communityId === activeCommunity.id + ? lastCommunityInitAttemptRef.current.initAttempt : -1; const community = useCommunityInit( activeCommunity, diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index ffb9df2bac..ed0666e0cb 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -71,6 +71,13 @@ async function resetCommunityState({ }: { resetAvatarState: boolean; }): Promise { + if (import.meta.env.MODE === "e2e") { + const e2eWindow = window as Window & { + __BUZZ_E2E_COMMUNITY_RESETS__?: number; + }; + e2eWindow.__BUZZ_E2E_COMMUNITY_RESETS__ = + (e2eWindow.__BUZZ_E2E_COMMUNITY_RESETS__ ?? 0) + 1; + } relayClient.disconnect(); await resetNavigationDeepLinkDrain(); resetRateLimitGate(); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 882a41349a..1a88d54093 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -403,6 +403,8 @@ type E2eConfig = { /** Delay (ms) for `apply_workspace` so e2e tests can observe the * community-switch gate. 0/undefined = instant. */ applyCommunityDelayMs?: number; + /** Hold `apply_workspace` until the E2E release seam is called. */ + deferApplyCommunity?: boolean; /** Reject `clear_pending_navigation_deep_links` with this message. */ clearPendingNavigationDeepLinksError?: string; openDmDelayMs?: number; @@ -1275,6 +1277,14 @@ declare global { /** Last payload written through the native clipboard command. */ __BUZZ_E2E_LAST_CLIPBOARD__?: { html: string | null; text: string }; __BUZZ_E2E_COMMANDS__?: string[]; + /** Number of community-scoped reset passes through useCommunityInit. */ + __BUZZ_E2E_COMMUNITY_RESETS__?: number; + /** Number of AppReady component mounts across the community boundary. */ + __BUZZ_E2E_APP_READY_MOUNTS__?: number; + /** Release `apply_workspace` calls held by `deferApplyCommunity`. */ + __BUZZ_E2E_RELEASE_APPLY_WORKSPACES__?: () => number; + /** Number of held `apply_workspace` calls. */ + __BUZZ_E2E_APPLY_WORKSPACES_PENDING__?: () => number; __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string; payload: unknown; @@ -1797,6 +1807,7 @@ type DeferredProfileUpdate = { let deferredProfileReadQueue: DeferredProfileRead[] = []; let nextProfileReadOrdinal = 1; let profileReadsReleased = false; +let deferredApplyCommunityQueue: Array<() => void> = []; let deferredProfileUpdateQueue: DeferredProfileUpdate[] = []; let nextProfileUpdateOrdinal = 1; let profileUpdatesReleased = false; @@ -11526,6 +11537,7 @@ export function maybeInstallE2eTauriMocks() { deferredProfileReadQueue = []; nextProfileReadOrdinal = 1; profileReadsReleased = false; + deferredApplyCommunityQueue = []; deferredProfileUpdateQueue = []; nextProfileUpdateOrdinal = 1; profileUpdatesReleased = false; @@ -11557,6 +11569,13 @@ export function maybeInstallE2eTauriMocks() { }; window.__BUZZ_E2E_THREAD_REPLIES_PENDING__ = () => deferredThreadRepliesQueue.length; + window.__BUZZ_E2E_RELEASE_APPLY_WORKSPACES__ = () => { + const queued = deferredApplyCommunityQueue.splice(0); + for (const release of queued) release(); + return queued.length; + }; + window.__BUZZ_E2E_APPLY_WORKSPACES_PENDING__ = () => + deferredApplyCommunityQueue.length; const releaseProfileRead = () => { const deferred = deferredProfileReadQueue.shift(); if (!deferred) return 0; @@ -11663,6 +11682,8 @@ export function maybeInstallE2eTauriMocks() { } mockWindows(config.mock?.windowLabel ?? "main"); window.__BUZZ_E2E_COMMANDS__ = []; + window.__BUZZ_E2E_COMMUNITY_RESETS__ = 0; + window.__BUZZ_E2E_APP_READY_MOUNTS__ = 0; window.__BUZZ_E2E_COMMAND_PAYLOADS__ = []; window.__BUZZ_E2E_COMMAND_LOG__ = []; window.__BUZZ_E2E_OBSERVER_CONTROLS__ = []; @@ -13088,6 +13109,17 @@ export function maybeInstallE2eTauriMocks() { return activeConfig?.mock?.linkPreviewMetadata ?? null; } case "apply_workspace": { + if (activeConfig?.mock?.deferApplyCommunity) { + return new Promise((resolve) => { + deferredApplyCommunityQueue.push(() => { + window.__BUZZ_E2E_COMMAND_LOG__?.push({ + command: "apply_workspace:released", + payload: null, + }); + resolve(); + }); + }); + } const applyDelayMs = activeConfig?.mock?.applyCommunityDelayMs ?? 0; if (applyDelayMs > 0) { return new Promise((resolve) => diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index ad2b6ea2b2..b49936be0d 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -74,6 +74,11 @@ async function setRelayConnectionState( const HOME_SEEN_STORAGE_KEY_PREFIX = "buzz-home-feed-seen.v1:"; const COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY = "buzz-community-onboarding-transaction.v1"; +const WELCOME_SURFACE_READY_EVENT = "buzz:onboarding-welcome-surface-ready"; +const ENTERING_CURTAIN_FADE_MS = 500; +const ENTERING_CURTAIN_SAFETY_MS = 8_000; +const ENTERING_CLOCK_STEP_MS = 100; +const ENTERING_CLOCK_MAX_ADVANCE_MS = 7_500; const ONE_PIXEL_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8); @@ -142,12 +147,14 @@ async function seedCommunityProfileStage(page: Page, id: string) { async function seedConnectingCorporateProfileTransaction( page: Page, { + addedCommunity = true, communityId = "e2e-enterprise-community", communityName = "Enterprise", pubkey = BLANK_TYLER_IDENTITY.pubkey, relayUrl, transactionId, }: { + addedCommunity?: boolean; communityId?: string; communityName?: string; pubkey?: string; @@ -157,7 +164,14 @@ async function seedConnectingCorporateProfileTransaction( ) { await seedActiveIdentity(page, { ...BLANK_TYLER_IDENTITY, pubkey }); await page.addInitScript( - ({ communityId, communityName, pubkey, relayUrl, transactionId }) => { + ({ + addedCommunity, + communityId, + communityName, + pubkey, + relayUrl, + transactionId, + }) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", @@ -185,13 +199,20 @@ async function seedConnectingCorporateProfileTransaction( relayUrl, communityName, communityId, - addedCommunity: true, + addedCommunity, createdAt: timestamp, updatedAt: timestamp, }), ); }, - { communityId, communityName, pubkey, relayUrl, transactionId }, + { + addedCommunity, + communityId, + communityName, + pubkey, + relayUrl, + transactionId, + }, ); } @@ -210,6 +231,64 @@ async function readCommunityOnboardingTransaction(page: Page) { }, COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY); } +async function readCommunityInitMetrics(page: Page) { + return page.evaluate(() => ({ + applyWorkspaceCalls: (window.__BUZZ_E2E_COMMANDS__ ?? []).filter( + (command) => command === "apply_workspace", + ).length, + communityResets: window.__BUZZ_E2E_COMMUNITY_RESETS__ ?? 0, + appReadyMounts: window.__BUZZ_E2E_APP_READY_MOUNTS__ ?? 0, + })); +} + +async function readCommunityRetirementState(page: Page) { + return page.evaluate(() => { + const testWindow = window as Window & { + __BUZZ_E2E_WELCOME_SURFACE_READY_AT__?: number | null; + }; + const rawTransaction = window.localStorage.getItem( + "buzz-community-onboarding-transaction.v1", + ); + const transaction = rawTransaction + ? (JSON.parse(rawTransaction) as { stage?: string }) + : null; + const welcomeContent = document.querySelector( + '[data-testid="message-channel-intro"]', + ); + const chatTitle = document.querySelector('[data-testid="chat-title"]'); + return { + clockNow: Date.now(), + transactionStage: transaction?.stage ?? null, + welcomeSurfaceReadyAt: + testWindow.__BUZZ_E2E_WELCOME_SURFACE_READY_AT__ ?? null, + applyWorkspaceCalls: (window.__BUZZ_E2E_COMMANDS__ ?? []).filter( + (command) => command === "apply_workspace", + ).length, + communityResets: window.__BUZZ_E2E_COMMUNITY_RESETS__ ?? 0, + appReadyMounts: window.__BUZZ_E2E_APP_READY_MOUNTS__ ?? 0, + enteringCurtainMounted: + document.querySelector( + '[data-testid="onboarding-entering-curtain"]', + ) !== null, + appLoadingGateMounted: + document.querySelector('[data-testid="app-loading-gate"]') !== null, + communitySwitchGateMounted: + document.querySelector('[data-testid="community-switch-gate"]') !== + null, + welcomeChatTitle: chatTitle?.textContent?.trim() ?? null, + welcomeContentMounted: welcomeContent !== null, + welcomeContentVisible: + welcomeContent !== null && welcomeContent.getClientRects().length > 0, + welcomeContentText: welcomeContent?.textContent ?? "", + }; + }); +} + +async function waitForCommunityLoadingGatesToSettle(page: Page) { + await expect(page.getByTestId("app-loading-gate")).toHaveCount(0); + await expect(page.getByTestId("community-switch-gate")).toHaveCount(0); +} + async function reopenSameRelayCommunityLink(page: Page, relayUrl: string) { await page.evaluate( (input) => { @@ -249,6 +328,42 @@ async function releaseOneProfileRead(page: Page) { return releasedOrdinal; } +async function releaseAllProfileReads(page: Page) { + const released = await page.evaluate(() => { + const release = window.__BUZZ_E2E_RELEASE_PROFILE_READS__; + if (!release) { + throw new Error("Profile read release helper is not installed."); + } + return release(); + }); + expect(released).toBeGreaterThan(0); + return released; +} + +async function waitForApplyWorkspacePending(page: Page, expected = 1) { + await expect + .poll( + () => + page.evaluate( + () => window.__BUZZ_E2E_APPLY_WORKSPACES_PENDING__?.() ?? 0, + ), + { message: "waiting for a held apply_workspace call" }, + ) + .toBeGreaterThanOrEqual(expected); +} + +async function releaseApplyWorkspaces(page: Page) { + const released = await page.evaluate(() => { + const release = window.__BUZZ_E2E_RELEASE_APPLY_WORKSPACES__; + if (!release) { + throw new Error("apply_workspace release helper is not installed."); + } + return release(); + }); + expect(released).toBeGreaterThan(0); + return released; +} + async function waitForScopedProfileUpdatePending(page: Page) { await expect .poll(() => @@ -3239,6 +3354,243 @@ test("first-community direct join reaches profile", async ({ page }) => { .toEqual({ communityCount: 1, transactionMatchesOnlyCommunity: true }); }); +test("completing a community join does not reinitialize after transaction clear", 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 page.addInitScript((eventName) => { + const testWindow = window as Window & { + __BUZZ_E2E_WELCOME_SURFACE_READY_AT__?: number | null; + }; + testWindow.__BUZZ_E2E_WELCOME_SURFACE_READY_AT__ = null; + window.addEventListener(eventName, () => { + testWindow.__BUZZ_E2E_WELCOME_SURFACE_READY_AT__ ??= Date.now(); + }); + }, WELCOME_SURFACE_READY_EVENT); + await installMockBridge( + page, + { + deferProfileReads: true, + profileHasEvent: false, + }, + { + relayWsUrl: "ws://localhost:3000", + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + // Install before navigation so every app timer uses the same clock. Keep + // page time flowing during startup and the profile flow; pausing too early + // would also pause the timers that make those screens actionable. + await page.clock.install({ + time: new Date("2026-10-01T00:00:00.000Z"), + }); + 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.poll(() => commandCount(page, "apply_workspace")).toBe(1); + await waitForProfileReadPending(page); + await releaseOneProfileRead(page); + await expect( + page.getByRole("heading", { name: "Build your profile" }), + ).toBeVisible(); + await expect + .poll(() => commandCount(page, "get_profile")) + .toBeGreaterThanOrEqual(3); + await waitForProfileReadPending(page, 2); + await releaseAllProfileReads(page); + + await page.getByTestId("community-profile-name-key").fill("New user"); + await page.getByTestId("community-profile-next").click(); + await expect( + page.getByRole("heading", { name: "Meet your starter team" }), + ).toBeVisible(); + await expect + .poll(() => readCommunityOnboardingTransaction(page)) + .toMatchObject({ stage: "team-intro" }); + // Pause at a strictly future virtual instant before entering. The one-second + // guard absorbs the clock RPC latency without reaching the 500 ms retirement + // timer, which does not exist until the entering curtain is ready. + const pauseAt = await page.evaluate(() => Date.now() + 1_000); + await page.clock.pauseAt(pauseAt); + await page.getByTestId("community-team-intro-enter").click(); + // Keep page time paused while the lazy Welcome route settles. Each bounded + // step gives timer-driven Suspense retries a chance to commit, but never + // crosses the real Welcome-ready fade or the entering safety deadline. + const isReadyForRetirement = ( + state: Awaited>, + ) => + state.transactionStage === "entering" && + state.welcomeSurfaceReadyAt !== null && + state.clockNow < state.welcomeSurfaceReadyAt + ENTERING_CURTAIN_FADE_MS && + state.enteringCurtainMounted && + !state.appLoadingGateMounted && + !state.communitySwitchGateMounted && + state.welcomeChatTitle?.includes("Welcome") === true && + state.welcomeContentMounted && + state.welcomeContentVisible && + state.welcomeContentText.includes("private welcome channel"); + let beforeRetirement: + | Awaited> + | undefined; + let enteringStartedAt: number | null = null; + let advancedMs = 0; + while (!beforeRetirement) { + const state = await readCommunityRetirementState(page); + if (enteringStartedAt === null && state.transactionStage === "entering") { + enteringStartedAt = state.clockNow; + } + if (isReadyForRetirement(state)) { + beforeRetirement = state; + break; + } + + const deadlines = [ + ENTERING_CLOCK_MAX_ADVANCE_MS - advancedMs, + state.welcomeSurfaceReadyAt === null + ? Number.POSITIVE_INFINITY + : state.welcomeSurfaceReadyAt + + ENTERING_CURTAIN_FADE_MS - + state.clockNow, + enteringStartedAt === null + ? Number.POSITIVE_INFINITY + : enteringStartedAt + ENTERING_CURTAIN_SAFETY_MS - state.clockNow, + ]; + const stepMs = Math.min( + ENTERING_CLOCK_STEP_MS, + ...deadlines.map((deadline) => deadline - 1), + ); + if (stepMs < 1) { + throw new Error( + `Welcome surface did not settle before its production deadline: ${JSON.stringify(state)}`, + ); + } + await page.clock.fastForward(stepMs); + advancedMs += stepMs; + // Yield the host turn so React can commit work released by the timer tick; + // this does not advance the paused page clock. + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + if (!beforeRetirement) { + throw new Error("The entering curtain never exposed the Welcome surface."); + } + expect(beforeRetirement).toMatchObject({ + transactionStage: "entering", + applyWorkspaceCalls: 1, + communityResets: 0, + appReadyMounts: 1, + enteringCurtainMounted: true, + welcomeContentMounted: true, + welcomeContentVisible: true, + }); + + const welcomeSurfaceReadyAt = beforeRetirement.welcomeSurfaceReadyAt; + if (welcomeSurfaceReadyAt === null) { + throw new Error("The Welcome surface-ready event was not captured."); + } + const retirementDeadline = welcomeSurfaceReadyAt + ENTERING_CURTAIN_FADE_MS; + const remainingToRetirement = retirementDeadline - beforeRetirement.clockNow; + expect(remainingToRetirement).toBeGreaterThan(0); + // Advance exactly to the deadline recorded from the actual production event. + // This fires the real fade callback; it does not fabricate or directly clear + // the onboarding transaction. + await page.clock.fastForward(remainingToRetirement); + await expect.poll(() => readCommunityOnboardingTransaction(page)).toBeNull(); + // Only after the production callback has retired the transaction may normal + // UI timers resume for the shared Welcome assertions. + await page.clock.resume(); + + await expect + .poll(async () => { + const state = await readCommunityRetirementState(page); + return { + applyWorkspaceCalls: state.applyWorkspaceCalls, + communityResets: state.communityResets, + appReadyMounts: state.appReadyMounts, + }; + }) + .toEqual({ + applyWorkspaceCalls: beforeRetirement.applyWorkspaceCalls, + communityResets: beforeRetirement.communityResets, + appReadyMounts: beforeRetirement.appReadyMounts, + }); + await waitForCommunityLoadingGatesToSettle(page); + await expectWelcomeView(page); + const afterRetirement = await readCommunityRetirementState(page); + expect(afterRetirement.applyWorkspaceCalls).toBe( + beforeRetirement.applyWorkspaceCalls, + ); + expect(afterRetirement.communityResets).toBe( + beforeRetirement.communityResets, + ); + expect(afterRetirement.appReadyMounts).toBe(beforeRetirement.appReadyMounts); + await expect(page.getByTestId("app-loading-gate")).toHaveCount(0); +}); + +test("first-community profile skip retires onboarding without reinitializing", 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, + { deferProfileReads: true, profileHasEvent: true }, + { + 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.poll(() => commandCount(page, "apply_workspace")).toBe(1); + await waitForProfileReadPending(page); + await waitForCommunityLoadingGatesToSettle(page); + const beforeRetirement = await readCommunityInitMetrics(page); + await releaseOneProfileRead(page); + + await expect.poll(() => readCommunityOnboardingTransaction(page)).toBeNull(); + await expect + .poll(() => readCommunityInitMetrics(page)) + .toMatchObject({ + applyWorkspaceCalls: 1, + communityResets: beforeRetirement.communityResets, + appReadyMounts: beforeRetirement.appReadyMounts + 1, + }); + await waitForCommunityLoadingGatesToSettle(page); + const afterRetirement = await readCommunityInitMetrics(page); + expect(afterRetirement.communityResets).toBe( + beforeRetirement.communityResets, + ); + expect(afterRetirement.appReadyMounts).toBe( + beforeRetirement.appReadyMounts + 1, + ); + await expect(page.getByTestId("app-loading-gate")).toHaveCount(0); +}); + test("community onboarding reuses an existing relay profile", async ({ page, }) => { @@ -3313,6 +3665,99 @@ test("community onboarding reuses an existing relay profile", async ({ ).toHaveAttribute("data-onboarding-direction", "backward"); }); +test("returning-user profile skip retires onboarding without reinitializing", async ({ + page, +}) => { + const relayUrl = "wss://returning-profile-skip.example"; + await seedConnectingCorporateProfileTransaction(page, { + relayUrl, + transactionId: "txn-returning-profile-skip", + }); + await installMockBridge( + page, + { + deferProfileReads: true, + profileHasEvent: true, + }, + { + relayWsUrl: relayUrl, + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + + await expect.poll(() => commandCount(page, "apply_workspace")).toBe(1); + await waitForProfileReadPending(page); + await waitForCommunityLoadingGatesToSettle(page); + const beforeRetirement = await readCommunityInitMetrics(page); + await releaseOneProfileRead(page); + + await expect.poll(() => readCommunityOnboardingTransaction(page)).toBeNull(); + await expect + .poll(() => readCommunityInitMetrics(page)) + .toMatchObject({ + applyWorkspaceCalls: 1, + communityResets: beforeRetirement.communityResets, + appReadyMounts: beforeRetirement.appReadyMounts + 1, + }); + await waitForCommunityLoadingGatesToSettle(page); + const afterRetirement = await readCommunityInitMetrics(page); + expect(afterRetirement.communityResets).toBe( + beforeRetirement.communityResets, + ); + expect(afterRetirement.appReadyMounts).toBe( + beforeRetirement.appReadyMounts + 1, + ); + await expect(page.getByTestId("app-loading-gate")).toHaveCount(0); +}); + +test("canceling onboarding while retaining the active community does not reinitialize", async ({ + page, +}) => { + const relayUrl = "wss://cancel-retained-community.example"; + await seedConnectingCorporateProfileTransaction(page, { + addedCommunity: false, + relayUrl, + transactionId: "txn-cancel-retained-community", + }); + await installMockBridge( + page, + { deferApplyCommunity: true }, + { + relayWsUrl: relayUrl, + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + + await expect(page.getByText("Connecting securely…")).toBeVisible(); + await expect.poll(() => commandCount(page, "apply_workspace")).toBe(1); + await waitForApplyWorkspacePending(page); + const beforeRetirement = await readCommunityInitMetrics(page); + await page.getByRole("button", { name: "Cancel" }).click(); + + await expect.poll(() => readCommunityOnboardingTransaction(page)).toBeNull(); + await releaseApplyWorkspaces(page); + await expect + .poll(() => + readCommunityInitMetrics(page).then((m) => m.applyWorkspaceCalls), + ) + .toBe(1); + await waitForCommunityLoadingGatesToSettle(page); + await expect + .poll(() => readCommunityInitMetrics(page).then((m) => m.appReadyMounts)) + .toBe(beforeRetirement.appReadyMounts + 1); + const afterRetirement = await readCommunityInitMetrics(page); + expect(afterRetirement.communityResets).toBe( + beforeRetirement.communityResets, + ); + expect(afterRetirement.appReadyMounts).toBe( + beforeRetirement.appReadyMounts + 1, + ); +}); + test("first-community direct join cancel returns to request access", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index edee4a4e61..f4dbeafa6f 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -317,6 +317,8 @@ type MockBridgeOptions = { }>; /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */ applyCommunityDelayMs?: number; + /** Hold `apply_workspace` until the E2E release seam is called. */ + deferApplyCommunity?: boolean; /** Reject `clear_pending_navigation_deep_links` with this message. */ clearPendingNavigationDeepLinksError?: string; openDmDelayMs?: number;