mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Will Pfleger
parent
7f98dc15f3
commit
cc3fcddfcb
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user