mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
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>
This commit is contained in:
@@ -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 ? <E2eAppReadyMountTelemetry /> : null;
|
||||
|
||||
if (onboarding.stage === "reset-failed") {
|
||||
return <ResetFailedScreen />;
|
||||
return (
|
||||
<>
|
||||
{e2eTelemetry}
|
||||
<ResetFailedScreen />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (onboarding.stage === "keyring-locked") {
|
||||
return <KeyringLockedScreen />;
|
||||
return (
|
||||
<>
|
||||
{e2eTelemetry}
|
||||
<KeyringLockedScreen />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (onboarding.stage === "relaunch-required") {
|
||||
return <RelaunchRequiredScreen />;
|
||||
return (
|
||||
<>
|
||||
{e2eTelemetry}
|
||||
<RelaunchRequiredScreen />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -353,35 +388,46 @@ function AppReady({
|
||||
(continueOnboarding && onboarding.stage === "blocking")
|
||||
) {
|
||||
return (
|
||||
<OnboardingFlow
|
||||
actions={onboarding.flow.actions}
|
||||
identityLost={onboarding.identityLost}
|
||||
initialProfile={onboarding.flow.initialProfile}
|
||||
initialProfileDecisionSettled={
|
||||
onboarding.flow.initialProfileDecisionSettled
|
||||
}
|
||||
key={onboarding.currentPubkey ?? "anonymous"}
|
||||
/>
|
||||
<>
|
||||
{e2eTelemetry}
|
||||
<OnboardingFlow
|
||||
actions={onboarding.flow.actions}
|
||||
identityLost={onboarding.identityLost}
|
||||
initialProfile={onboarding.flow.initialProfile}
|
||||
initialProfileDecisionSettled={
|
||||
onboarding.flow.initialProfileDecisionSettled
|
||||
}
|
||||
key={onboarding.currentPubkey ?? "anonymous"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (onboarding.stage === "blocking") {
|
||||
return isCommunitySwitch ? <CommunitySwitchGate /> : <AppLoadingGate />;
|
||||
return (
|
||||
<>
|
||||
{e2eTelemetry}
|
||||
{isCommunitySwitch ? <CommunitySwitchGate /> : <AppLoadingGate />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EncryptedBackupProvider
|
||||
onOpenSettings={() =>
|
||||
void router.navigate({
|
||||
to: "/settings",
|
||||
search: { section: "profile" },
|
||||
})
|
||||
}
|
||||
>
|
||||
<KnownAgentPubkeysProvider>
|
||||
<RouterProvider router={router} />
|
||||
</KnownAgentPubkeysProvider>
|
||||
</EncryptedBackupProvider>
|
||||
<>
|
||||
{e2eTelemetry}
|
||||
<EncryptedBackupProvider
|
||||
onOpenSettings={() =>
|
||||
void router.navigate({
|
||||
to: "/settings",
|
||||
search: { section: "profile" },
|
||||
})
|
||||
}
|
||||
>
|
||||
<KnownAgentPubkeysProvider>
|
||||
<RouterProvider router={router} />
|
||||
</KnownAgentPubkeysProvider>
|
||||
</EncryptedBackupProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -71,6 +71,13 @@ async function resetCommunityState({
|
||||
}: {
|
||||
resetAvatarState: boolean;
|
||||
}): Promise<void> {
|
||||
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();
|
||||
|
||||
@@ -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<void>((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) =>
|
||||
|
||||
@@ -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<ReturnType<typeof readCommunityRetirementState>>,
|
||||
) =>
|
||||
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<ReturnType<typeof readCommunityRetirementState>>
|
||||
| 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,
|
||||
}) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user