diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 91d4a942eb..75c378ad23 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -32,7 +32,6 @@ import { markCommunityOnboardingComplete, resolveProfileCheckAction, isTransactionCurrentStage, - isTransactionStillConnecting, } from "@/features/onboarding/communityOnboarding"; import { CommunityOnboardingFlow } from "@/features/onboarding/ui/CommunityOnboardingFlow"; import { @@ -93,6 +92,31 @@ const INITIAL_RENDER_READY_EVENT = "initial-render-ready"; type BootSplashPhase = "holding" | "fading" | "done"; +type CommunityOnboardingWorkOwner = { + transactionId: string; + initAttempt: number; +}; + +function sameCommunityOnboardingWorkOwner( + a: CommunityOnboardingWorkOwner | null, + b: CommunityOnboardingWorkOwner, +): boolean { + return ( + a?.transactionId === b.transactionId && a.initAttempt === b.initAttempt + ); +} + +function isTransactionCurrentGenerationStage( + live: Parameters[0], + owner: CommunityOnboardingWorkOwner, + stage: Parameters[2], +): boolean { + return ( + (live?.initAttempt ?? 0) === owner.initAttempt && + isTransactionCurrentStage(live, owner.transactionId, stage) + ); +} + function useInitialRenderReady() { useLayoutEffect(() => { if (!isTauri()) { @@ -384,10 +408,12 @@ function CommunityApp({ } = useCommunities(); const communityOnboarding = useCommunityOnboarding(); const connectingTransactionRef = useRef(null); - // Tracks the ID of the profile-check request that has been launched for the - // current connecting transaction. Prevents the effect from launching a - // second request if it re-runs while a fetch is in flight. - const profileCheckTransactionRef = useRef(null); + // Tracks the transaction generation that launched the downstream profile + // branch for the current connecting transaction. Same-relay reopen preserves + // the transaction id while incrementing initAttempt, so the generation is part + // of both launch ownership and async completion ownership. + const profileCheckTransactionRef = + useRef(null); // Always reflects the live transaction object so async callbacks can perform // an atomic check of both ID and stage before mutating state. const transactionRef = useRef(communityOnboarding.transaction); @@ -546,6 +572,11 @@ function CommunityApp({ useEffect(() => { if (transaction?.stage !== "connecting") { connectingTransactionRef.current = null; + } + if ( + transaction?.stage !== "connecting" && + transaction?.stage !== "corporate-profile" + ) { profileCheckTransactionRef.current = null; } }, [transaction?.stage]); @@ -582,11 +613,23 @@ function CommunityApp({ transaction, ]); useEffect(() => { - if (transaction?.stage !== "connecting" || !targetIsReady) return; + const canLaunchProfileDownstream = + transaction?.stage === "connecting" || + (transaction?.stage === "corporate-profile" && + !transaction.error && + Boolean(enterpriseProfile)); + if (!canLaunchProfileDownstream || !targetIsReady) return; const transactionId = transaction.id; const relayUrl = transaction.relayUrl; - if (profileCheckTransactionRef.current === transactionId) return; - profileCheckTransactionRef.current = transactionId; + const workOwner = { transactionId, initAttempt: communityInitAttempt }; + if ( + sameCommunityOnboardingWorkOwner( + profileCheckTransactionRef.current, + workOwner, + ) + ) + return; + profileCheckTransactionRef.current = workOwner; if (enterpriseProfile) { if (!identityPubkey) { @@ -612,10 +655,11 @@ function CommunityApp({ }) .then(() => { if ( - !isTransactionCurrentStage(transactionRef.current, transactionId, [ - "connecting", - "corporate-profile", - ]) + !isTransactionCurrentGenerationStage( + transactionRef.current, + workOwner, + ["connecting", "corporate-profile"], + ) ) return; communityOnboarding.update( @@ -625,10 +669,11 @@ function CommunityApp({ }) .catch((error) => { if ( - !isTransactionCurrentStage(transactionRef.current, transactionId, [ - "connecting", - "corporate-profile", - ]) + !isTransactionCurrentGenerationStage( + transactionRef.current, + workOwner, + ["connecting", "corporate-profile"], + ) ) return; profileCheckTransactionRef.current = null; @@ -649,11 +694,17 @@ function CommunityApp({ // resolveProfileCheckAction resolves exactly once (Promise.race + timer // cleared on settle), so no settled flag is needed here. void resolveProfileCheckAction(getProfile, 10_000).then((result) => { - // Atomic staleness guard via isTransactionStillConnecting: the - // transaction must still be the same one that launched this request - // AND still be in connecting. Covers cancel+replacement (B's ID !== A's) - // and cancel-without-replacement (transactionRef.current is null). - if (!isTransactionStillConnecting(transactionRef.current, transactionId)) + // Atomic staleness guard: the transaction must still be the same + // generation that launched this request and still be in connecting. + // Covers same-relay retry (same ID, newer initAttempt), cancel + + // replacement (B's ID !== A's), and cancel without replacement. + if ( + !isTransactionCurrentGenerationStage( + transactionRef.current, + workOwner, + "connecting", + ) + ) return; if (result.action === "skip") { @@ -672,8 +723,10 @@ function CommunityApp({ transaction?.stage, transaction?.id, transaction?.relayUrl, + transaction?.error, enterpriseProfile, identityPubkey, + communityInitAttempt, ]); // During "entering" the transaction stays alive as a curtain: the app mounts // underneath (already pointed at the Welcome channel route) while the diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7aaa8d9334..882a41349a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -449,6 +449,7 @@ type E2eConfig = { /** Hold `get_profile` responses until the E2E release seam is invoked. */ deferProfileReads?: boolean; profileReadError?: string; + profileReadErrors?: (string | null)[]; /** Override whether get_profile reports a real kind:0 event. */ profileHasEvent?: boolean; profileUpdateError?: string; @@ -1632,14 +1633,30 @@ declare global { __BUZZ_E2E_HOLD_USERS_BATCH__?: (hold: boolean) => number; /** Number of `get_users_batch` calls currently held. */ __BUZZ_E2E_USERS_BATCH_PENDING__?: () => number; + /** Release the oldest `get_profile` response held by `deferProfileReads`. */ + __BUZZ_E2E_RELEASE_ONE_PROFILE_READ__?: () => number; + /** Oldest-to-newest ordinals for `get_profile` responses currently held. */ + __BUZZ_E2E_PROFILE_READ_PENDING_ORDINALS__?: () => number[]; /** Release every `get_profile` response held by `deferProfileReads`. */ __BUZZ_E2E_RELEASE_PROFILE_READS__?: () => number; /** Number of `get_profile` responses currently held. */ __BUZZ_E2E_PROFILE_READS_PENDING__?: () => number; + /** Release the oldest `update_profile_at_relay` response held by `deferProfileUpdates`. */ + __BUZZ_E2E_RELEASE_ONE_PROFILE_UPDATE__?: () => number; + /** Oldest-to-newest ordinals for `update_profile_at_relay` responses currently held. */ + __BUZZ_E2E_PROFILE_UPDATE_PENDING_ORDINALS__?: () => number[]; /** Release every `update_profile_at_relay` response held by `deferProfileUpdates`. */ __BUZZ_E2E_RELEASE_PROFILE_UPDATES__?: () => number; /** Number of `update_profile_at_relay` responses currently held. */ __BUZZ_E2E_PROFILE_UPDATES_PENDING__?: () => number; + /** Append a mocked Rust-side pending community deep link. */ + __BUZZ_E2E_ENQUEUE_COMMUNITY_DEEP_LINK__?: (pending: { + id: string; + kind: "connect" | "join" | "add-community"; + relayUrl: string; + code?: string | null; + name?: string | null; + }) => number; /** Uploads that passed mock-native registration and began relay work. */ __BUZZ_E2E_LINK_PREVIEW_UPLOAD_STARTS__?: number; /** Hold renderer-owned media fetches until their cancellation command. */ @@ -1766,18 +1783,22 @@ let deferredLinkPreviewMetadataQueue: Array<() => void> = []; let deferredLinkPreviewUploadQueue: Array<() => void> = []; let deferredThreadRepliesQueue: Array<() => void> = []; type DeferredProfileRead = { + ordinal: number; reject: (reason: unknown) => void; resolve: (value: unknown) => void; run: () => Promise; }; type DeferredProfileUpdate = { + ordinal: number; reject: (reason: unknown) => void; resolve: (value: unknown) => void; run: () => Promise; }; let deferredProfileReadQueue: DeferredProfileRead[] = []; +let nextProfileReadOrdinal = 1; let profileReadsReleased = false; let deferredProfileUpdateQueue: DeferredProfileUpdate[] = []; +let nextProfileUpdateOrdinal = 1; let profileUpdatesReleased = false; // ── get_users_batch hold seam ─────────────────────────────────────────────── // Toggled at runtime by `__BUZZ_E2E_HOLD_USERS_BATCH__(hold)` rather than fixed @@ -6830,6 +6851,11 @@ async function handleGetChannels( async function runGetProfile(config: E2eConfig | undefined) { const identity = getIdentity(config); + const profileReadErrors = config?.mock?.profileReadErrors; + const nextProfileReadError = profileReadErrors?.shift(); + if (nextProfileReadError) { + throw new Error(nextProfileReadError); + } const profileReadDelayMs = config?.mock?.profileReadDelayMs ?? 0; if (profileReadDelayMs > 0) { await new Promise((resolve) => { @@ -6887,10 +6913,12 @@ async function handleGetProfile(config: E2eConfig | undefined) { return new Promise((resolve, reject) => { deferredProfileReadQueue.push({ + ordinal: nextProfileReadOrdinal, resolve, reject, run: () => runGetProfile(config), }); + nextProfileReadOrdinal += 1; }); } @@ -7000,10 +7028,12 @@ async function handleUpdateProfileAtRelay( return new Promise((resolve, reject) => { deferredProfileUpdateQueue.push({ + ordinal: nextProfileUpdateOrdinal, resolve, reject, run: () => runUpdateProfile(args, config), }); + nextProfileUpdateOrdinal += 1; }); } @@ -11494,8 +11524,10 @@ export function maybeInstallE2eTauriMocks() { deferredLinkPreviewUploadQueue = []; deferredThreadRepliesQueue = []; deferredProfileReadQueue = []; + nextProfileReadOrdinal = 1; profileReadsReleased = false; deferredProfileUpdateQueue = []; + nextProfileUpdateOrdinal = 1; profileUpdatesReleased = false; holdUsersBatch = false; heldUsersBatchReleases = []; @@ -11525,6 +11557,25 @@ export function maybeInstallE2eTauriMocks() { }; window.__BUZZ_E2E_THREAD_REPLIES_PENDING__ = () => deferredThreadRepliesQueue.length; + const releaseProfileRead = () => { + const deferred = deferredProfileReadQueue.shift(); + if (!deferred) return 0; + window.__BUZZ_E2E_COMMAND_LOG__?.push({ + command: "get_profile:released", + payload: { ordinal: deferred.ordinal }, + }); + void deferred + .run() + .then(deferred.resolve, deferred.reject) + .finally(() => { + window.__BUZZ_E2E_COMMAND_LOG__?.push({ + command: "get_profile:settled", + payload: { ordinal: deferred.ordinal }, + }); + }); + return deferred.ordinal; + }; + window.__BUZZ_E2E_RELEASE_ONE_PROFILE_READ__ = releaseProfileRead; window.__BUZZ_E2E_RELEASE_PROFILE_READS__ = () => { profileReadsReleased = true; const queued = deferredProfileReadQueue.splice(0); @@ -11535,6 +11586,27 @@ export function maybeInstallE2eTauriMocks() { }; window.__BUZZ_E2E_PROFILE_READS_PENDING__ = () => deferredProfileReadQueue.length; + window.__BUZZ_E2E_PROFILE_READ_PENDING_ORDINALS__ = () => + deferredProfileReadQueue.map(({ ordinal }) => ordinal); + const releaseProfileUpdate = () => { + const deferred = deferredProfileUpdateQueue.shift(); + if (!deferred) return 0; + window.__BUZZ_E2E_COMMAND_LOG__?.push({ + command: "update_profile_at_relay:released", + payload: { ordinal: deferred.ordinal }, + }); + void deferred + .run() + .then(deferred.resolve, deferred.reject) + .finally(() => { + window.__BUZZ_E2E_COMMAND_LOG__?.push({ + command: "update_profile_at_relay:settled-ordinal", + payload: { ordinal: deferred.ordinal }, + }); + }); + return deferred.ordinal; + }; + window.__BUZZ_E2E_RELEASE_ONE_PROFILE_UPDATE__ = releaseProfileUpdate; window.__BUZZ_E2E_RELEASE_PROFILE_UPDATES__ = () => { profileUpdatesReleased = true; const queued = deferredProfileUpdateQueue.splice(0); @@ -11545,6 +11617,16 @@ export function maybeInstallE2eTauriMocks() { }; window.__BUZZ_E2E_PROFILE_UPDATES_PENDING__ = () => deferredProfileUpdateQueue.length; + window.__BUZZ_E2E_PROFILE_UPDATE_PENDING_ORDINALS__ = () => + deferredProfileUpdateQueue.map(({ ordinal }) => ordinal); + window.__BUZZ_E2E_ENQUEUE_COMMUNITY_DEEP_LINK__ = (pending) => { + mockPendingCommunityDeepLinks.push({ + ...pending, + code: pending.code ?? null, + name: pending.name ?? null, + }); + return mockPendingCommunityDeepLinks.length; + }; window.__BUZZ_E2E_HOLD_USERS_BATCH__ = (hold: boolean) => { holdUsersBatch = hold; // Releasing on the way out of the hold, not on the way in, is what lets a diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 4c07fcd0cc..ad2b6ea2b2 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -1,5 +1,11 @@ import { hexToBytes } from "@noble/hashes/utils.js"; -import { expect, test, type Locator, type Page } from "@playwright/test"; +import { + expect, + test, + type Locator, + type Page, + type TestInfo, +} from "@playwright/test"; import { nsecEncode, npubEncode } from "nostr-tools/nip19"; import { @@ -197,12 +203,52 @@ async function readCommunityOnboardingTransaction(page: Page) { id: string; relayUrl: string; stage: string; + initAttempt?: number; error?: string; }) : null; }, COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY); } +async function reopenSameRelayCommunityLink(page: Page, relayUrl: string) { + await page.evaluate( + (input) => { + const enqueue = window.__BUZZ_E2E_ENQUEUE_COMMUNITY_DEEP_LINK__; + const emit = window.__BUZZ_E2E_EMIT_TAURI_EVENT__; + if (!enqueue || !emit) { + throw new Error("Community deep-link E2E helpers are not installed."); + } + enqueue({ + id: `reopen-${Date.now()}`, + kind: "connect", + relayUrl: input.relayUrl, + }); + void emit("deep-link-connect", null); + }, + { relayUrl }, + ); +} + +async function waitForProfileReadPending(page: Page, expected = 1) { + await expect + .poll(() => + page.evaluate(() => window.__BUZZ_E2E_PROFILE_READS_PENDING__?.() ?? 0), + ) + .toBeGreaterThanOrEqual(expected); +} + +async function releaseOneProfileRead(page: Page) { + const releasedOrdinal = await page.evaluate(() => { + const release = window.__BUZZ_E2E_RELEASE_ONE_PROFILE_READ__; + if (!release) { + throw new Error("Profile read release helper is not installed."); + } + return release(); + }); + expect(releasedOrdinal).toBeGreaterThan(0); + return releasedOrdinal; +} + async function waitForScopedProfileUpdatePending(page: Page) { await expect .poll(() => @@ -215,6 +261,109 @@ async function waitForScopedProfileUpdatePending(page: Page) { .toBeGreaterThan(0); } +async function writeProfileUpdateGenerationArtifact( + page: Page, + testInfo: TestInfo, + label: string, + snapshots: unknown[], +) { + const finalState = await page.evaluate(() => ({ + commandLog: window.__BUZZ_E2E_COMMAND_LOG__ ?? [], + pendingUpdateOrdinals: + window.__BUZZ_E2E_PROFILE_UPDATE_PENDING_ORDINALS__?.() ?? [], + pendingUpdateCount: window.__BUZZ_E2E_PROFILE_UPDATES_PENDING__?.() ?? 0, + transactionRaw: window.localStorage.getItem( + "buzz-community-onboarding-transaction.v1", + ), + })); + await testInfo.attach(label, { + body: JSON.stringify({ snapshots, finalState }, null, 2), + contentType: "application/json", + }); +} + +async function captureProfileUpdateGenerationSnapshot( + page: Page, + label: string, +) { + return page.evaluate((snapshotLabel) => { + const transactionRaw = window.localStorage.getItem( + "buzz-community-onboarding-transaction.v1", + ); + const transaction = transactionRaw + ? (JSON.parse(transactionRaw) as { + id?: string; + initAttempt?: number; + stage?: string; + }) + : null; + return { + label: snapshotLabel, + pendingUpdateOrdinals: + window.__BUZZ_E2E_PROFILE_UPDATE_PENDING_ORDINALS__?.() ?? [], + pendingUpdateCount: window.__BUZZ_E2E_PROFILE_UPDATES_PENDING__?.() ?? 0, + settledUpdateOrdinals: (window.__BUZZ_E2E_COMMAND_LOG__ ?? []) + .filter( + (entry) => + entry.command === "update_profile_at_relay:settled-ordinal", + ) + .map( + (entry) => (entry.payload as { ordinal?: number } | null)?.ordinal, + ), + transaction: transaction + ? { + id: transaction.id, + initAttempt: transaction.initAttempt, + stage: transaction.stage, + } + : null, + }; + }, label); +} + +async function attachProfileUpdateGenerationSnapshots( + page: Page, + testInfo: TestInfo, + label: string, + snapshots: unknown[], +) { + try { + await writeProfileUpdateGenerationArtifact( + page, + testInfo, + label, + snapshots, + ); + } catch { + // Diagnostic attachment must not mask the primary assertion failure. + } +} + +async function releaseOneProfileUpdateAndWaitForSettlement(page: Page) { + const releasedOrdinal = await page.evaluate(() => { + const release = window.__BUZZ_E2E_RELEASE_ONE_PROFILE_UPDATE__; + if (!release) { + throw new Error("Profile update release helper is not installed."); + } + return release(); + }); + expect(releasedOrdinal).toBeGreaterThan(0); + await expect + .poll(() => + page.evaluate((ordinal) => { + return (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).some((entry) => { + if (entry.command !== "update_profile_at_relay:settled-ordinal") { + return false; + } + const payload = entry.payload as { ordinal?: number } | null; + return payload?.ordinal === ordinal; + }); + }, releasedOrdinal), + ) + .toBe(true); + return releasedOrdinal; +} + async function releaseScopedProfileUpdateAndWaitForSettlement(page: Page) { const settledBefore = await page.evaluate( () => @@ -2200,6 +2349,342 @@ test("joining enterprise login retry reruns init for the active community", asyn await expect(page.getByText("Browser login was rejected")).toHaveCount(0); }); +test("same-relay reopen fences stale profile read success by init attempt", async ({ + page, +}) => { + const relayUrl = "wss://profile-read-generation-success.example"; + const transactionId = "txn-profile-read-generation-success"; + await seedConnectingCorporateProfileTransaction(page, { + relayUrl, + transactionId, + }); + await installMockBridge( + page, + { deferProfileReads: true, profileHasEvent: false }, + { + relayWsUrl: relayUrl, + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + + await waitForProfileReadPending(page, 1); + const generationA = await readCommunityOnboardingTransaction(page); + expect(generationA).toMatchObject({ + id: transactionId, + stage: "connecting", + }); + expect(generationA).not.toHaveProperty("initAttempt"); + + await reopenSameRelayCommunityLink(page, relayUrl); + await expect + .poll(() => readCommunityOnboardingTransaction(page)) + .toMatchObject({ + id: transactionId, + stage: "connecting", + initAttempt: 1, + }); + await waitForProfileReadPending(page, 2); + + await releaseOneProfileRead(page); + await expect + .poll(() => readCommunityOnboardingTransaction(page)) + .toMatchObject({ + id: transactionId, + stage: "connecting", + initAttempt: 1, + }); + await expect( + page.getByRole("heading", { name: "Build your profile" }), + ).toHaveCount(0); + await expect + .poll(() => commandCount(page, "get_profile")) + .toBeGreaterThanOrEqual(2); + await waitForProfileReadPending(page, 1); + + await releaseOneProfileRead(page); + await expect( + page.getByRole("heading", { name: "Build your profile" }), + ).toBeVisible(); + await expect + .poll(() => readCommunityOnboardingTransaction(page)) + .toMatchObject({ id: transactionId, stage: "profile", initAttempt: 1 }); +}); + +test("same-relay reopen fences stale profile read rejection by init attempt", async ({ + page, +}) => { + const relayUrl = "wss://profile-read-generation-rejection.example"; + const transactionId = "txn-profile-read-generation-rejection"; + await seedConnectingCorporateProfileTransaction(page, { + relayUrl, + transactionId, + }); + await installMockBridge( + page, + { + deferProfileReads: true, + profileHasEvent: false, + profileReadErrors: ["stale profile read failed", null], + }, + { + relayWsUrl: relayUrl, + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + + await waitForProfileReadPending(page, 1); + await reopenSameRelayCommunityLink(page, relayUrl); + await expect + .poll(() => readCommunityOnboardingTransaction(page)) + .toMatchObject({ + id: transactionId, + stage: "connecting", + initAttempt: 1, + }); + await waitForProfileReadPending(page, 2); + + await releaseOneProfileRead(page); + await expect + .poll(() => readCommunityOnboardingTransaction(page)) + .toMatchObject({ + id: transactionId, + stage: "connecting", + initAttempt: 1, + }); + await expect(page.getByText("stale profile read failed")).toHaveCount(0); + await expect( + page.getByRole("heading", { name: "Build your profile" }), + ).toHaveCount(0); + + await releaseOneProfileRead(page); + await expect( + page.getByRole("heading", { name: "Build your profile" }), + ).toBeVisible(); + await expect + .poll(() => readCommunityOnboardingTransaction(page)) + .toMatchObject({ id: transactionId, stage: "profile", initAttempt: 1 }); +}); + +test("same-relay reopen fences stale corporate profile save success by init attempt", async ({ + page, +}, testInfo) => { + const snapshots: unknown[] = []; + try { + const relayUrl = "wss://profile-save-generation-success.example"; + const transactionId = "txn-profile-save-generation-success"; + await seedConnectingCorporateProfileTransaction(page, { + relayUrl, + transactionId, + }); + await installMockBridge( + page, + { + enterpriseLoginGate: { status: "required" }, + enterpriseAuth: { + email: "brad@example.com", + expiresAt: "2099-01-01T00:00:00Z", + profileProjection: { + username: "seiler", + displayName: "Brad Seiler", + }, + }, + deferProfileUpdates: true, + }, + { + relayWsUrl: relayUrl, + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + + await expect(page.getByText("Saving your company profile…")).toBeVisible(); + await waitForScopedProfileUpdatePending(page); + snapshots.push( + await captureProfileUpdateGenerationSnapshot(page, "A queued"), + ); + await reopenSameRelayCommunityLink(page, relayUrl); + await expect + .poll(() => readCommunityOnboardingTransaction(page)) + .toMatchObject({ + id: transactionId, + stage: "corporate-profile", + initAttempt: 1, + }); + snapshots.push( + await captureProfileUpdateGenerationSnapshot(page, "reopen applied"), + ); + await expect + .poll(() => + page.evaluate( + () => window.__BUZZ_E2E_PROFILE_UPDATES_PENDING__?.() ?? 0, + ), + ) + .toBeGreaterThanOrEqual(2); + snapshots.push( + await captureProfileUpdateGenerationSnapshot(page, "B queued"), + ); + + const releasedA = await releaseOneProfileUpdateAndWaitForSettlement(page); + expect(releasedA).toBe(1); + snapshots.push( + await captureProfileUpdateGenerationSnapshot(page, "A released/settled"), + ); + await expect + .poll(() => readCommunityOnboardingTransaction(page)) + .toMatchObject({ + id: transactionId, + stage: "corporate-profile", + initAttempt: 1, + }); + await expect( + page.getByRole("heading", { name: "Meet your starter team" }), + ).toHaveCount(0); + await expect + .poll(() => + page.evaluate( + () => window.__BUZZ_E2E_PROFILE_UPDATE_PENDING_ORDINALS__?.() ?? [], + ), + ) + .toContain(2); + + const releasedB = await releaseOneProfileUpdateAndWaitForSettlement(page); + expect(releasedB).toBe(2); + snapshots.push( + await captureProfileUpdateGenerationSnapshot(page, "B released/settled"), + ); + await expect( + page.getByRole("heading", { name: "Meet your starter team" }), + ).toBeVisible(); + await expect + .poll(() => readCommunityOnboardingTransaction(page)) + .toMatchObject({ + id: transactionId, + stage: "team-intro", + initAttempt: 1, + }); + } finally { + await attachProfileUpdateGenerationSnapshots( + page, + testInfo, + "profile-save-generation-success-diagnostics", + snapshots, + ); + } +}); + +test("same-relay reopen fences stale corporate profile save rejection by init attempt", async ({ + page, +}, testInfo) => { + const snapshots: unknown[] = []; + try { + const staleError = "Stale generation corporate profile failure."; + const relayUrl = "wss://profile-save-generation-rejection.example"; + const transactionId = "txn-profile-save-generation-rejection"; + await seedConnectingCorporateProfileTransaction(page, { + relayUrl, + transactionId, + }); + await installMockBridge( + page, + { + enterpriseLoginGate: { status: "required" }, + enterpriseAuth: { + email: "brad@example.com", + expiresAt: "2099-01-01T00:00:00Z", + profileProjection: { + username: "seiler", + displayName: "Brad Seiler", + }, + }, + deferProfileUpdates: true, + profileUpdateErrors: [staleError, null], + }, + { + relayWsUrl: relayUrl, + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + + await expect(page.getByText("Saving your company profile…")).toBeVisible(); + await waitForScopedProfileUpdatePending(page); + snapshots.push( + await captureProfileUpdateGenerationSnapshot(page, "A queued"), + ); + await reopenSameRelayCommunityLink(page, relayUrl); + await expect + .poll(() => readCommunityOnboardingTransaction(page)) + .toMatchObject({ + id: transactionId, + stage: "corporate-profile", + initAttempt: 1, + }); + snapshots.push( + await captureProfileUpdateGenerationSnapshot(page, "reopen applied"), + ); + await expect + .poll(() => + page.evaluate( + () => window.__BUZZ_E2E_PROFILE_UPDATES_PENDING__?.() ?? 0, + ), + ) + .toBeGreaterThanOrEqual(2); + snapshots.push( + await captureProfileUpdateGenerationSnapshot(page, "B queued"), + ); + + const releasedA = await releaseOneProfileUpdateAndWaitForSettlement(page); + expect(releasedA).toBe(1); + snapshots.push( + await captureProfileUpdateGenerationSnapshot(page, "A released/settled"), + ); + await expect + .poll(() => readCommunityOnboardingTransaction(page)) + .toMatchObject({ + id: transactionId, + stage: "corporate-profile", + initAttempt: 1, + }); + await expect(page.getByText(staleError)).toHaveCount(0); + await expect + .poll(() => + page.evaluate( + () => window.__BUZZ_E2E_PROFILE_UPDATE_PENDING_ORDINALS__?.() ?? [], + ), + ) + .toContain(2); + + const releasedB = await releaseOneProfileUpdateAndWaitForSettlement(page); + expect(releasedB).toBe(2); + snapshots.push( + await captureProfileUpdateGenerationSnapshot(page, "B released/settled"), + ); + await expect( + page.getByRole("heading", { name: "Meet your starter team" }), + ).toBeVisible(); + await expect + .poll(() => readCommunityOnboardingTransaction(page)) + .toMatchObject({ + id: transactionId, + stage: "team-intro", + initAttempt: 1, + }); + } finally { + await attachProfileUpdateGenerationSnapshots( + page, + testInfo, + "profile-save-generation-rejection-diagnostics", + snapshots, + ); + } +}); + test("returning enterprise user can cancel pending browser login and retry into the app", async ({ page, }) => {