fix(ui): surface rejected WhatsApp actions without stalled refreshes (#129800)

This commit is contained in:
Peter Steinberger
2026-08-25 20:22:05 -07:00
committed by GitHub
parent cb6c508fe3
commit c8382f9db9
3 changed files with 127 additions and 13 deletions
@@ -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(
{
+56 -1
View File
@@ -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<typeof createChannelCapability>) =>
channels.startWhatsApp(false),
preservesQr: false,
},
{
operation: "scan wait",
method: "web.login.wait",
invoke: (channels: ReturnType<typeof createChannelCapability>) => channels.waitWhatsApp(),
preservesQr: true,
},
{
operation: "logout",
method: "channels.logout",
invoke: (channels: ReturnType<typeof createChannelCapability>) => 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: [],
+12 -12
View File
@@ -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;