diff --git a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts index 058d80bff09c..295dc245d020 100644 --- a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts +++ b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts @@ -137,6 +137,65 @@ suite.define(() => { ); }); + it("shows rejected WhatsApp login immediately without waiting for channel status", async () => { + await suite.withPage( + { locale: "en-US", serviceWorkers: "block", viewport: { height: 900, width: 1280 } }, + async ({ page }) => { + const gateway = await installMockGateway(page, { + methodResponses: { + "channels.status": { + ts: Date.now(), + channelOrder: ["whatsapp"], + channelLabels: { whatsapp: "WhatsApp" }, + channels: { + whatsapp: { configured: true, linked: true, running: true, connected: true }, + }, + channelAccounts: {}, + channelDefaultAccountId: {}, + }, + "channels.pairing.list": { + accounts: [], + requests: [], + commandOwnerConfigured: true, + limits: { pendingPerAccount: 3, ttlMs: 3_600_000 }, + }, + }, + }); + + expect((await page.goto(`${suite.server.baseUrl}settings/channels`))?.status()).toBe(200); + await page.locator(".channels-item", { hasText: "WhatsApp" }).first().click(); + const detail = page.locator(".channels-detail"); + const relink = detail.getByRole("button", { name: "Relink" }); + await relink.waitFor(); + const statusReadsBefore = (await gateway.getRequests("channels.status")).length; + await gateway.deferNext("channels.status", { probe: true }); + await gateway.deferNext("web.login.start"); + + await relink.click(); + await gateway.waitForRequest("web.login.start"); + await gateway.rejectDeferred("web.login.start", { + code: "INVALID_REQUEST", + message: "WhatsApp login rejected", + }); + + if (captureUiProofEnabled) { + await mkdir(uiProofArtifactDir, { recursive: true }); + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join( + uiProofArtifactDir, + `whatsapp-mutation-${process.env.OPENCLAW_UI_PROOF_LABEL ?? "rejected"}.png`, + ), + }); + } + await expect.poll(() => detail.textContent()).toContain("WhatsApp login rejected"); + await expect.poll(() => relink.isEnabled()).toBe(true); + expect(await gateway.getRequests("channels.status")).toHaveLength(statusReadsBefore); + }, + ); + }); + it("confirms the explicit default account and preserves a no-op logout", async () => { await suite.withPage( { diff --git a/ui/src/lib/channels/index.test.ts b/ui/src/lib/channels/index.test.ts index 4b6cebbecbdf..ecfea8d7ecbc 100644 --- a/ui/src/lib/channels/index.test.ts +++ b/ui/src/lib/channels/index.test.ts @@ -329,11 +329,66 @@ describe("channels controller WhatsApp logout", () => { expect(channels.state.whatsappLoginMessage).toBe("credential cleanup failed"); expect(channels.state.whatsappLoginQrDataUrl).toBe("data:image/png;base64,current-qr"); expect(channels.state.whatsappLoginConnected).toBe(true); - expect(request.mock.calls.filter(([method]) => method === "channels.status")).toHaveLength(1); + expect(request.mock.calls.filter(([method]) => method === "channels.status")).toHaveLength(0); channels.dispose(); }); }); +describe("channels controller WhatsApp mutation failures", () => { + it.each([ + { + operation: "login", + method: "web.login.start", + invoke: (channels: ReturnType) => + channels.startWhatsApp(false), + preservesQr: false, + }, + { + operation: "scan wait", + method: "web.login.wait", + invoke: (channels: ReturnType) => channels.waitWhatsApp(), + preservesQr: true, + }, + { + operation: "logout", + method: "channels.logout", + invoke: (channels: ReturnType) => channels.logoutWhatsApp(), + preservesQr: true, + }, + ])( + "publishes a rejected $operation without probing channel status", + async ({ method, invoke, preservesQr }) => { + const request = vi.fn(async (requestedMethod: string) => { + if (requestedMethod === method) { + throw new Error("WhatsApp request rejected"); + } + return createChannelsSnapshot("unexpected refresh"); + }); + const channels = createChannelCapability({ + snapshot: { client: { request }, phase: "connected" }, + subscribe: () => () => undefined, + } as never); + channels.state.whatsappLoginQrDataUrl = "data:image/png;base64,current-qr"; + channels.state.whatsappLoginConnected = true; + const updates: Array<{ busy: boolean; message: string | null }> = []; + channels.subscribe((state) => { + updates.push({ busy: state.whatsappBusy, message: state.whatsappLoginMessage }); + }); + + await invoke(channels); + + expect( + request.mock.calls.filter(([requestedMethod]) => requestedMethod === "channels.status"), + ).toHaveLength(0); + expect(updates.at(-1)).toEqual({ busy: false, message: "WhatsApp request rejected" }); + expect(channels.state.whatsappLoginQrDataUrl).toBe( + preservesQr ? "data:image/png;base64,current-qr" : null, + ); + channels.dispose(); + }, + ); +}); + describe("channels controller DM pairing", () => { const emptyPairing: ChannelsPairingListResult = { accounts: [], diff --git a/ui/src/lib/channels/index.ts b/ui/src/lib/channels/index.ts index fb0f8c2a5a9b..3bfc7724e309 100644 --- a/ui/src/lib/channels/index.ts +++ b/ui/src/lib/channels/index.ts @@ -469,12 +469,12 @@ async function startWhatsAppLogin( state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null; state.whatsappLoginConnected = typeof res.connected === "boolean" ? res.connected : null; } catch (err) { - if (!isCurrentWhatsAppOperation(state, operation)) { - return false; + if (isCurrentWhatsAppOperation(state, operation)) { + state.whatsappLoginMessage = formatUiError(err); + state.whatsappLoginQrDataUrl = null; + state.whatsappLoginConnected = null; } - state.whatsappLoginMessage = formatUiError(err); - state.whatsappLoginQrDataUrl = null; - state.whatsappLoginConnected = null; + return false; } finally { if (isCurrentWhatsAppOperation(state, operation)) { state.whatsappBusy = false; @@ -510,11 +510,11 @@ async function waitWhatsAppLogin(state: ChannelsState, accountId?: string): Prom state.whatsappLoginQrDataUrl = null; } } catch (err) { - if (!isCurrentWhatsAppOperation(state, operation)) { - return false; + if (isCurrentWhatsAppOperation(state, operation)) { + state.whatsappLoginMessage = formatUiError(err); + state.whatsappLoginConnected = null; } - state.whatsappLoginMessage = formatUiError(err); - state.whatsappLoginConnected = null; + return false; } finally { if (isCurrentWhatsAppOperation(state, operation)) { state.whatsappBusy = false; @@ -544,10 +544,10 @@ async function logoutWhatsApp(state: ChannelsState, accountId?: string): Promise state.whatsappLoginMessage = t("channels.whatsapp.logoutNotCleared"); } } catch (err) { - if (!isCurrentWhatsAppOperation(state, operation)) { - return false; + if (isCurrentWhatsAppOperation(state, operation)) { + state.whatsappLoginMessage = formatUiError(err); } - state.whatsappLoginMessage = formatUiError(err); + return false; } finally { if (isCurrentWhatsAppOperation(state, operation)) { state.whatsappBusy = false;