test(onboarding): force controller into phase 3 to prove reconnect card success path

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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-07-02 15:38:22 -04:00
co-authored by Will Pfleger
parent 7f98dc15f3
commit cc3fcddfcb
2 changed files with 149 additions and 14 deletions
+16
View File
@@ -662,6 +662,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 }>;
@@ -2079,6 +2080,8 @@ const mockReminderEvents: RelayEvent[] = [];
let mockRelayMembers: RawRelayMember[] = [];
const mockSockets = new Map<number, MockSocket>();
let mockWebsocketSendMutexWedged = false;
/** Number of upcoming `connectMockSocket` calls that should reject immediately. */
let mockConnectFailCount = 0;
const realSockets = new Map<number, WebSocket>();
let mockManagedAgents: MockManagedAgent[] = [];
@@ -6837,6 +6840,11 @@ async function connectMockSocket(args: { onMessage: unknown }) {
return new Promise<number>(() => {});
}
if (mockConnectFailCount > 0) {
mockConnectFailCount--;
throw new Error("Mock connect failure (test-injected).");
}
const wsId = nextSocketId++;
const handler = resolveHandler(args.onMessage);
@@ -7144,6 +7152,7 @@ export function maybeInstallE2eTauriMocks() {
resetMockMesh();
resetMockUserStatuses();
mockWebsocketSendMutexWedged = false;
mockConnectFailCount = 0;
mockWindows("main");
window.__BUZZ_E2E_COMMANDS__ = [];
window.__BUZZ_E2E_COMMAND_PAYLOADS__ = [];
@@ -7262,6 +7271,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.
+133 -14
View File
@@ -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");