mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
Fence onboarding profile work by init attempt
Signed-off-by: coder 0 <d97ebdbb198c7237c94f84ea8bb8a73583ea067407eebd0062abbb3962527fb1@buzz.block.builderlab.xyz> Signed-off-by: bb-expert <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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<typeof isTransactionCurrentStage>[0],
|
||||
owner: CommunityOnboardingWorkOwner,
|
||||
stage: Parameters<typeof isTransactionCurrentStage>[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<string | null>(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<string | null>(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<CommunityOnboardingWorkOwner | null>(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
|
||||
|
||||
@@ -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<unknown>;
|
||||
};
|
||||
type DeferredProfileUpdate = {
|
||||
ordinal: number;
|
||||
reject: (reason: unknown) => void;
|
||||
resolve: (value: unknown) => void;
|
||||
run: () => Promise<unknown>;
|
||||
};
|
||||
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<void>((resolve) => {
|
||||
@@ -6887,10 +6913,12 @@ async function handleGetProfile(config: E2eConfig | undefined) {
|
||||
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
deferredProfileReadQueue.push({
|
||||
ordinal: nextProfileReadOrdinal,
|
||||
resolve,
|
||||
reject,
|
||||
run: () => runGetProfile(config),
|
||||
});
|
||||
nextProfileReadOrdinal += 1;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7000,10 +7028,12 @@ async function handleUpdateProfileAtRelay(
|
||||
|
||||
return new Promise<unknown>((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
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user