From badf316997ca9d5c7d11b52cdceb3e06112bda84 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 2 Jul 2026 15:38:22 -0400 Subject: [PATCH] test(onboarding): force controller into phase 3 to prove reconnect card success path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous E2E tests used setRelayConnectionState() before the click but left the mock socket alive (wsId !== null), so ensureConnected() always fast-pathed and reconnect() returned true — the phase-3 connection-state effect in ProfileStep.tsx was never exercised. Add __BUZZ_E2E_FAIL_NEXT_MOCK_CONNECT__ seam to e2eBridge.ts: arms a counter that makes the next connectMockSocket call throw before assigning wsId. This forces connect() to reject, withDeadline to race-reject, the fast-path catch to fire, and the controller to enter phase 3. forcePhase3() helper: - Arms the fail counter N times - Closes all live sockets (disconnectMockSocket, sets wsId=null) - Drives emitter to 'disconnected' immediately so useRelayConnection() commits a non-connected state (bypasses the 2s debounce on transient states); without this the hook never transitions away from 'connected' and the ProfileStep effect doesn't re-fire when we later drive success Tests (3 total): 1. Phase-3 positive: click -> assert 'Connecting' (phase-3 proof) -> drive connected -> assert 'Connected' + auto-dismiss 2. Phase-3 negative: click -> assert 'Connecting' -> hold disconnected -> assert no 'Connected' (5 fail slots block retries for 15+ seconds) 3. No-click negative: drive connected without clicking -> assert hadActiveReconnectRef guard blocks markSuccess() (unchanged) Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/testing/e2eBridge.ts | 16 +++ desktop/tests/e2e/onboarding.spec.ts | 147 ++++++++++++++++++++++++--- 2 files changed, 149 insertions(+), 14 deletions(-) diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7e5ab1183d..2742253c15 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -667,6 +667,7 @@ declare global { __BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => ConnectionState; __BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void; __BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__?: () => number; + __BUZZ_E2E_FAIL_NEXT_MOCK_CONNECT__?: () => void; __BUZZ_E2E_SET_MESH__?: (mesh: { admitted?: boolean; models?: Array<{ id: string; name: string | null }>; @@ -2084,6 +2085,8 @@ const mockReminderEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); let mockWebsocketSendMutexWedged = false; +/** Number of upcoming `connectMockSocket` calls that should reject immediately. */ +let mockConnectFailCount = 0; const realSockets = new Map(); let mockManagedAgents: MockManagedAgent[] = []; @@ -6844,6 +6847,11 @@ async function connectMockSocket(args: { onMessage: unknown }) { return new Promise(() => {}); } + if (mockConnectFailCount > 0) { + mockConnectFailCount--; + throw new Error("Mock connect failure (test-injected)."); + } + const wsId = nextSocketId++; const handler = resolveHandler(args.onMessage); @@ -7151,6 +7159,7 @@ export function maybeInstallE2eTauriMocks() { resetMockMesh(); resetMockUserStatuses(); mockWebsocketSendMutexWedged = false; + mockConnectFailCount = 0; mockWindows("main"); window.__BUZZ_E2E_COMMANDS__ = []; window.__BUZZ_E2E_COMMAND_PAYLOADS__ = []; @@ -7269,6 +7278,13 @@ export function maybeInstallE2eTauriMocks() { for (const socketId of socketIds) disconnectMockSocket(socketId); return socketIds.length; }; + window.__BUZZ_E2E_FAIL_NEXT_MOCK_CONNECT__ = () => { + // Makes the next connectMockSocket call throw immediately (before the + // wsId is assigned), so relayClientSession.connect() rejects and the + // fast-path preconnect fails synchronously. Use in tests that need the + // controller to enter phase 3 without waiting for FAST_PATH_TIMEOUT_MS. + mockConnectFailCount++; + }; // Tests flip `admitted` to exercise the denial path: mesh_ensure_client_node // rejects when not admitted, which proves relay membership is the gate and // that the create flow surfaces denial copy without spawning the agent. diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index bd958a2fba..5d0a81942e 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -1340,10 +1340,79 @@ test("membership denial can import a different invited key", async ({ await expectHomeView(page); }); -test("onboarding relay reconnect — click shows Connected then auto-dismisses", async ({ +/** + * Force the next `connectMockSocket` call(s) to throw and close all live mock + * sockets so that when the reconnect button is clicked, the fast-path + * `preconnect()` call fails synchronously and the controller enters phase 3. + * + * Also drives the relay connection state to "disconnected" immediately so + * `useRelayConnection()` (which debounces transient states) commits a + * non-"connected" state before the click. This is required for the + * connection-state effect in `ProfileStep.tsx` to fire when we later drive + * the state back to "connected" — without it the hook may never transition + * away from "connected" (debounce absorbs "reconnecting") and the effect + * doesn't re-fire. + * + * @param failCount How many upcoming connect attempts should fail. Use 1 for + * tests that need exactly one fail (positive path); use a higher value for + * tests that need to hold disconnected across several poll retries. + */ +async function forcePhase3(page: Page, failCount = 1) { + // Arm the fail counter first, then close live sockets. The close triggers + // resetConnection() which sets wsId=null — so when the click fires + // ensureConnected(), it cannot short-circuit on the wsId check and must call + // connect(), which throws via the armed counter. + await page.evaluate((count) => { + const win = window as Window & { + __BUZZ_E2E_FAIL_NEXT_MOCK_CONNECT__?: () => void; + __BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__?: () => number; + __BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (state: string) => void; + }; + if ( + typeof win.__BUZZ_E2E_FAIL_NEXT_MOCK_CONNECT__ !== "function" || + typeof win.__BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__ !== "function" || + typeof win.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ !== "function" + ) { + throw new Error("Phase-3 test seams are not installed."); + } + for (let i = 0; i < count; i++) { + win.__BUZZ_E2E_FAIL_NEXT_MOCK_CONNECT__(); + } + win.__BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__(); + // Drive the emitter to "disconnected" immediately so useRelayConnection() + // (which debounces transient states like "reconnecting") commits a + // non-connected state before the click. This ensures the hook's state + // changes from "disconnected" → "connected" when we drive success later, + // causing the ProfileStep effect to re-fire and call markSuccess(). + win.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__("disconnected"); + }, failCount); + + // Wait for the hook-visible state to settle to "disconnected" so that + // useRelayConnection() has committed the state change before we click. + await page.waitForFunction(() => { + const win = window as Window & { + __BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => string; + }; + if (typeof win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__ !== "function") { + return false; + } + return win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__() === "disconnected"; + }); +} + +test("onboarding relay reconnect — click forces phase 3, recovered relay shows Connected and auto-dismisses", async ({ page, }) => { - // Produce the relay reconnect card via a relay-unreachable profile save error. + // Verifies that OnboardingRelayConnectionErrorCard's phase-3 success path + // works: when reconnect() returns false (controller entered phase 3 because + // the fast-path preconnect failed), the card must show "Connected" and + // auto-dismiss once the relay connection state becomes "connected". + // + // This test forces phase 3 by arming __BUZZ_E2E_FAIL_NEXT_MOCK_CONNECT__ + // and closing the live socket before clicking. The "Connecting" pending UI + // that appears after the click proves phase 3 is active (not the fast path). + // Without that phase-3 proof assertion, a phase-1 win would silently pass + // the test while leaving the component's connection-state effect uncovered. await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, @@ -1361,17 +1430,23 @@ test("onboarding relay reconnect — click shows Connected then auto-dismisses", 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"); + // Arm the fail-connect seam and close live sockets so the click's fast-path + // preconnect() call throws and the controller enters phase 3 immediately. + await forcePhase3(page); - // 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. + // ── Phase-3 proof ──────────────────────────────────────────────────────── + // The card must show the pending "Connecting" title while the controller is + // in phase 3. If this assertion fails, phase 1 won (fast path succeeded) and + // the seam did not fire — meaning the phase-3 success path was never tested. + await expect(card).toContainText("Connecting", { timeout: 3_000 }); + await expect(card).not.toContainText("Connected"); + + // Drive the relay to "connected" via the state seam. This triggers the + // connection-state effect in OnboardingRelayConnectionErrorCard + // (relayConnectionState === "connected" && hadActiveReconnectRef.current) + // which calls markSuccess() — the phase-3 success path under test. await setRelayConnectionState(page, "connected"); await expect(card).toContainText("Connected", { timeout: 5_000 }); @@ -1382,6 +1457,53 @@ test("onboarding relay reconnect — click shows Connected then auto-dismisses", await expect(card).toBeHidden({ timeout: 10_000 }); }); +test("onboarding relay reconnect — phase 3 active but relay stays disconnected does not show Connected", async ({ + page, +}) => { + // Verifies two guards together: + // 1. hadActiveReconnectRef: if the relay becomes "connected" without a prior + // click, the card must NOT show Connected (no spurious markSuccess). + // 2. Phase-3 idle: if the user clicked but the relay never recovers, the card + // stays in the pending/error state — markSuccess() is never called. + // + // A higher fail count (5) keeps the connection failing across all poll + // retries within the test window, so no background reconnect can sneak in + // and make the test pass via phase 1 while phase 3 was intended. + 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(); + + // Arm 5 fail slots: 1 for the click's fast path + 4 to block poll retries. + // Phase-3 POLL_INTERVAL_MS = 3s, so 4 extra slots guard a 12s window — + // well beyond the 500ms assertion wait below. + await forcePhase3(page, 5); + + await page.getByTestId("onboarding-reconnect-relay").click(); + + // Confirm phase 3 is active before asserting the negative case. + // If this fails, phase 1 won and the negative assertion would be vacuous. + await expect(card).toContainText("Connecting", { timeout: 3_000 }); + + // Hold: do NOT drive "connected". The hadActiveReconnectRef guard and + // the phase-3 polling hold must collectively keep the card in the error + // or pending state — markSuccess() must not fire without a "connected" event. + await page.waitForTimeout(500); + await expect(card).toBeVisible(); + await expect(card).not.toContainText("Connected"); +}); + test("onboarding relay reconnect — connected without a prior click does not show Connected", async ({ page, }) => { @@ -1405,10 +1527,7 @@ test("onboarding relay reconnect — connected without a prior click does not sh 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 + // Drive to connected WITHOUT clicking — this simulates a spontaneous // background recovery. The hadActiveReconnectRef guard must block markSuccess(). await setRelayConnectionState(page, "connected");