From 7f98dc15f380ad0d339c6bafef64e661cc9fe360 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 2 Jul 2026 15:17:54 -0400 Subject: [PATCH] test(onboarding): replace mirror test with E2E specs for reconnect card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OnboardingRelayConnectionErrorCard.test.mjs unit test was a mirror state machine — it duplicated the component's ref/effect logic in the test file itself, meaning it would pass even if ProfileStep.tsx regressed. Thufir flagged this as not exercising the production path. Replace with two E2E tests in onboarding.spec.ts that drive the real component against the real relay state seam: - 'click shows Connected then auto-dismisses': produces the error card via a relay-unreachable profile save error, drives relay to disconnected, clicks onboarding-reconnect-relay, drives relay to connected, and asserts the card transitions to 'Connected' then auto-dismisses. Exercises the full hadActiveReconnectRef guard and markSuccess() path through real code. - 'connected without a prior click does not show Connected': same card setup, drives disconnected → connected with no click, asserts the card stays in error state. Guards against spurious success on background recovery without user action. Also adds the setRelayConnectionState helper to onboarding.spec.ts, copied from sidebar-relay-card.spec.ts (same seam, same bidirectional guard). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- ...nboardingRelayConnectionErrorCard.test.mjs | 143 ------------------ desktop/tests/e2e/onboarding.spec.ts | 134 ++++++++++++++++ 2 files changed, 134 insertions(+), 143 deletions(-) delete mode 100644 desktop/src/features/onboarding/ui/OnboardingRelayConnectionErrorCard.test.mjs 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"); +});