diff --git a/desktop/src/features/onboarding/ui/OnboardingRelayConnectionErrorCard.test.mjs b/desktop/src/features/onboarding/ui/OnboardingRelayConnectionErrorCard.test.mjs deleted file mode 100644 index 6e6ddf260a..0000000000 --- a/desktop/src/features/onboarding/ui/OnboardingRelayConnectionErrorCard.test.mjs +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Unit tests for the OnboardingRelayConnectionErrorCard phase-3 reconnect contract. - * - * Tests the guard logic that ties relay connection state to markSuccess(): - * - phase-3 path (reconnect() returns false): card marks success when relay - * transitions to "connected" while a reconnect was attempted. - * - disconnected state never triggers success. - * - no prior reconnect attempt: connected state is ignored. - * - * These tests exercise the observable contract without React rendering by - * simulating the component's ref/effect logic as a pure state machine. - */ - -import assert from "node:assert/strict"; -import test from "node:test"; - -/** - * Minimal simulation of the OnboardingRelayConnectionErrorCard guard logic. - * - * Models the three-variable contract: - * hadActiveReconnect (ref) — set true when a reconnect is started - * relayConnectionState — driven by useRelayConnection - * hasSuccess (state) — becomes true when guard fires markSuccess - */ -function makeCard() { - let hadActiveReconnect = false; - let hasSuccess = false; - let markSuccessCallCount = 0; - - const markSuccess = () => { - hasSuccess = true; - markSuccessCallCount++; - }; - - // Simulates what runConnectivityAction does: arm the ref, call reconnect, - // then either mark success synchronously (phase 1) or leave ref armed (phase 3). - const startReconnect = async (reconnectResult) => { - hadActiveReconnect = true; - const didReconnect = await reconnectResult; - if (didReconnect !== false) { - // Phase 1 sync success — clear ref and mark immediately. - hadActiveReconnect = false; - markSuccess(); - } - // Phase 3: ref stays armed, waiting for connection-state effect. - }; - - // Simulates the useEffect(() => { if (connected && ref) markSuccess() }) - const driveConnectionState = (state) => { - if (state === "connected" && hadActiveReconnect) { - hadActiveReconnect = false; - markSuccess(); - } - }; - - return { - startReconnect, - driveConnectionState, - get hasSuccess() { - return hasSuccess; - }, - get markSuccessCallCount() { - return markSuccessCallCount; - }, - }; -} - -// ── Phase 3: async success via connection-state ─────────────────────────────── - -test("phase-3 path — reconnect() returns false, connected fires, markSuccess called once", async () => { - const card = makeCard(); - - // Simulate reconnect() entering phase 3 (returns false). - await card.startReconnect(Promise.resolve(false)); - - // Controller eventually drives relay to connected. - card.driveConnectionState("connected"); - - assert.equal(card.hasSuccess, true, "card marks success on connected"); - assert.equal(card.markSuccessCallCount, 1, "markSuccess called exactly once"); -}); - -test("phase-3 path — disconnected state does not mark success", async () => { - const card = makeCard(); - - await card.startReconnect(Promise.resolve(false)); - - // Drive through several non-connected states. - card.driveConnectionState("disconnected"); - card.driveConnectionState("reconnecting"); - card.driveConnectionState("stalled"); - - assert.equal(card.hasSuccess, false, "no success on non-connected states"); - assert.equal(card.markSuccessCallCount, 0, "markSuccess never called"); -}); - -test("no reconnect attempt — connected state does not mark success", async () => { - const card = makeCard(); - - // Drive to connected without any reconnect attempt. - card.driveConnectionState("connected"); - - assert.equal( - card.hasSuccess, - false, - "pre-existing connected state does not mark success", - ); - assert.equal(card.markSuccessCallCount, 0, "markSuccess never called"); -}); - -test("phase-1 sync success — markSuccess fires immediately, ref cleared, later connected is ignored", async () => { - const card = makeCard(); - - // Phase 1: reconnect() returns true (sync success). - await card.startReconnect(Promise.resolve(true)); - - assert.equal(card.hasSuccess, true, "success marked synchronously"); - assert.equal(card.markSuccessCallCount, 1, "called once"); - - // A subsequent connected transition should NOT call markSuccess again. - card.driveConnectionState("connected"); - - assert.equal( - card.markSuccessCallCount, - 1, - "no double-success on later connected", - ); -}); - -test("phase-3 path — connected fires multiple times, markSuccess still called only once", async () => { - const card = makeCard(); - - await card.startReconnect(Promise.resolve(false)); - - card.driveConnectionState("connected"); - card.driveConnectionState("connected"); // spurious second emission - - assert.equal( - card.markSuccessCallCount, - 1, - "ref cleared after first connected, no double-success", - ); -}); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 4f10b076f8..bd958a2fba 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -4,6 +4,60 @@ import { nsecEncode } from "nostr-tools/nip19"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +type RelayConnectionState = + | "connected" + | "connecting" + | "disconnected" + | "idle" + | "reconnecting" + | "stalled"; + +/** + * Drive the mock relay connection state via the E2E bridge seam. + * When targeting a degraded state, waits for baseline "connected" first so + * the mock auth handshake can't race the override back to "connected". + */ +async function setRelayConnectionState( + page: Page, + state: RelayConnectionState, +) { + if (state !== "connected") { + await page.waitForFunction(() => { + const win = window as Window & { + __BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => string; + __BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown; + }; + return ( + typeof win.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function" && + typeof win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__ === "function" && + win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__() === "connected" + ); + }); + } else { + await page.waitForFunction( + () => + typeof ( + window as Window & { + __BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown; + } + ).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function", + ); + } + await page.evaluate((nextState) => { + const testWindow = window as Window & { + __BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: ( + state: RelayConnectionState, + ) => void; + }; + const setConnectionState = + testWindow.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__; + if (!setConnectionState) { + throw new Error("Mock relay connection state helper is not installed."); + } + setConnectionState(nextState); + }, state); +} + const E2E_IDENTITY_OVERRIDE_STORAGE_KEY = "buzz:e2e-identity-override.v1"; const HOME_SEEN_STORAGE_KEY_PREFIX = "buzz-home-feed-seen.v1:"; const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8); @@ -1285,3 +1339,83 @@ test("membership denial can import a different invited key", async ({ await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expectHomeView(page); }); + +test("onboarding relay reconnect — click shows Connected then auto-dismisses", async ({ + page, +}) => { + // Produce the relay reconnect card via a relay-unreachable profile save error. + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + await installMockBridge( + page, + { + profileUpdateError: "relay unreachable: could not connect to relay", + }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await page.getByTestId("onboarding-display-name").fill("Morty QA"); + await page.getByTestId("onboarding-next").click(); + + const card = page.getByTestId("onboarding-relay-reconnect-card"); + await expect(card).toBeVisible(); + await expect(card).toContainText("Can't reach the relay"); + + // Drive degraded before clicking so the card is in the expected error state. + await setRelayConnectionState(page, "disconnected"); + + // Click the reconnect button. The controller attempts reconnect; the mock + // relay reconnects successfully (fast-path or via the state seam). Either + // path should result in the card transitioning to Connected and then + // auto-dismissing. + await page.getByTestId("onboarding-reconnect-relay").click(); + + // Drive connected to ensure the controller's connection-state path fires + // and the component's hadActiveReconnectRef guard is satisfied. + await setRelayConnectionState(page, "connected"); + + await expect(card).toContainText("Connected", { timeout: 5_000 }); + await expect(card).not.toContainText("Can't reach the relay"); + + // Auto-dismiss fires after ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS + // (2500ms). Allow generous headroom for CI. + await expect(card).toBeHidden({ timeout: 10_000 }); +}); + +test("onboarding relay reconnect — connected without a prior click does not show Connected", async ({ + page, +}) => { + // Tests the hadActiveReconnectRef guard: if the relay transitions to + // "connected" without the user having clicked the reconnect button, the card + // must NOT show Connected. This guards against spurious success on initial + // connection or background recovery with no user action. + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + await installMockBridge( + page, + { + profileUpdateError: "relay unreachable: could not connect to relay", + }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await page.getByTestId("onboarding-display-name").fill("Morty QA"); + await page.getByTestId("onboarding-next").click(); + + const card = page.getByTestId("onboarding-relay-reconnect-card"); + await expect(card).toBeVisible(); + + // Drive to disconnected state (no reconnect click has happened). + await setRelayConnectionState(page, "disconnected"); + + // Drive to connected WITHOUT clicking — this would happen on a spontaneous + // background recovery. The hadActiveReconnectRef guard must block markSuccess(). + await setRelayConnectionState(page, "connected"); + + // Wait long enough for any spurious markSuccess() to fire, then assert the + // card stayed in the error state. + await page.waitForTimeout(500); + await expect(card).toBeVisible(); + await expect(card).toContainText("Can't reach the relay"); + await expect(card).not.toContainText("Connected"); +});