mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 22:16:19 +00:00
* feat(channels): add WeChat QR login and binding recovery * fix(channels): enforce single-worker WeChat QR login and preserve bot ID Reject QR login endpoints when multiple Gateway workers are configured, while keeping manual token setup available. Preserve the configured bot ID when the provider omits it or returns an empty value. Add regression coverage for worker guards and credential persistence. * fix(channels): sync WeChat completion state on provider updates Show the connected step when refreshed provider data confirms the binding, so the dialog no longer waits indefinitely after polling is cancelled. Add regression tests for provider updates during pending poll and binding requests, including late responses and expiry. * fix(channels): preserve WeChat pairing codes across waits and redirects --------- Co-authored-by: YxinMiracle <“939157765@qq.com”>
202 lines
7.2 KiB
TypeScript
202 lines
7.2 KiB
TypeScript
import { afterEach, beforeEach, expect, it, rs } from "@rstest/core";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import {
|
|
act,
|
|
cleanup,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
} from "@testing-library/react";
|
|
import { StrictMode } from "react";
|
|
|
|
rs.mock("@/core/channels/api", () => ({
|
|
connectChannelProvider: rs.fn(),
|
|
listChannelProviders: rs.fn(),
|
|
listChannelConnections: rs.fn(),
|
|
}));
|
|
|
|
import {
|
|
WechatQRCompletion,
|
|
type PendingWechatBinding,
|
|
} from "@/components/workspace/channels/wechat-qr-completion";
|
|
import {
|
|
connectChannelProvider,
|
|
listChannelProviders,
|
|
listChannelConnections,
|
|
} from "@/core/channels/api";
|
|
import type { ChannelProvider, ChannelConnection } from "@/core/channels/types";
|
|
import { I18nProvider } from "@/core/i18n/context";
|
|
|
|
beforeEach(() => {
|
|
rs.mocked(listChannelProviders).mockResolvedValue({
|
|
enabled: true,
|
|
providers: [],
|
|
});
|
|
rs.mocked(listChannelConnections).mockResolvedValue([]);
|
|
rs.mocked(connectChannelProvider).mockResolvedValue({
|
|
provider: "wechat",
|
|
mode: "binding_code",
|
|
code: "demo",
|
|
instruction: "Send /connect demo",
|
|
expires_in: 600,
|
|
});
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
rs.useRealTimers();
|
|
rs.resetAllMocks();
|
|
});
|
|
function mount(connected = false, bindingToResume?: PendingWechatBinding) {
|
|
const onDone = rs.fn();
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
const view = (connected: boolean) => (
|
|
<StrictMode>
|
|
<QueryClientProvider client={queryClient}>
|
|
<I18nProvider initialLocale="en-US">
|
|
<WechatQRCompletion
|
|
provider={
|
|
{
|
|
provider: "wechat",
|
|
connection_status: connected ? "connected" : "not_connected",
|
|
} as ChannelProvider
|
|
}
|
|
onDone={onDone}
|
|
onRestart={rs.fn()}
|
|
bindingToResume={bindingToResume}
|
|
/>
|
|
</I18nProvider>
|
|
</QueryClientProvider>
|
|
</StrictMode>
|
|
);
|
|
const { rerender } = render(view(connected));
|
|
return {
|
|
onDone,
|
|
updateConnection: (connected: boolean) => rerender(view(connected)),
|
|
};
|
|
}
|
|
|
|
it("keeps saved credentials and the binding instruction visible until connected", async () => {
|
|
const { onDone } = mount();
|
|
expect(await screen.findByText("/connect demo")).toBeTruthy();
|
|
expect(screen.getByText("Token saved securely")).toBeTruthy();
|
|
expect(onDone).not.toHaveBeenCalled();
|
|
expect(connectChannelProvider).toHaveBeenCalledTimes(1);
|
|
rs.mocked(listChannelConnections).mockResolvedValue([
|
|
{ provider: "wechat", status: "connected" } as ChannelConnection,
|
|
]);
|
|
await screen.findByText("WeChat is connected", {}, { timeout: 4000 });
|
|
fireEvent.click(screen.getByRole("button", { name: "Done" }));
|
|
expect(onDone).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("shows a stable success step when no user binding is required", async () => {
|
|
const { onDone } = mount(true);
|
|
expect(screen.getByText("WeChat is connected")).toBeTruthy();
|
|
expect(connectChannelProvider).not.toHaveBeenCalled();
|
|
fireEvent.click(screen.getByRole("button", { name: "Done" }));
|
|
expect(onDone).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("shows success when the provider connects before the binding poll finishes", async () => {
|
|
rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
|
let finishPoll!: (connections: ChannelConnection[]) => void;
|
|
rs.mocked(listChannelConnections).mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
finishPoll = resolve;
|
|
}),
|
|
);
|
|
const { onDone, updateConnection } = mount();
|
|
await act(() => rs.advanceTimersByTimeAsync(1));
|
|
expect(screen.getByText("/connect demo")).toBeTruthy();
|
|
await act(() => rs.advanceTimersByTimeAsync(2000));
|
|
expect(listChannelConnections).toHaveBeenCalledTimes(1);
|
|
|
|
updateConnection(true);
|
|
expect(screen.getByText("WeChat is connected")).toBeTruthy();
|
|
expect(screen.queryByText("/connect demo")).toBeNull();
|
|
expect(screen.queryByRole("button", { name: "Scan again" })).toBeNull();
|
|
expect(screen.getByRole("button", { name: "Done" })).toBeTruthy();
|
|
|
|
await act(async () => finishPoll([]));
|
|
await act(() => rs.advanceTimersByTimeAsync(600_000));
|
|
expect(screen.getByText("WeChat is connected")).toBeTruthy();
|
|
expect(listChannelConnections).toHaveBeenCalledTimes(1);
|
|
expect(connectChannelProvider).toHaveBeenCalledTimes(1);
|
|
fireEvent.click(screen.getByRole("button", { name: "Done" }));
|
|
expect(onDone).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps success when the provider connects while a binding request is pending", async () => {
|
|
rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
|
let failBinding!: (reason: Error) => void;
|
|
rs.mocked(connectChannelProvider).mockReturnValueOnce(
|
|
new Promise((_resolve, reject) => {
|
|
failBinding = reject;
|
|
}),
|
|
);
|
|
const { updateConnection } = mount();
|
|
await act(() => rs.advanceTimersByTimeAsync(1));
|
|
expect(connectChannelProvider).toHaveBeenCalledTimes(1);
|
|
|
|
updateConnection(true);
|
|
expect(screen.getByText("WeChat is connected")).toBeTruthy();
|
|
await act(async () => failBinding(new Error("late binding failure")));
|
|
await act(() => rs.advanceTimersByTimeAsync(600_000));
|
|
expect(screen.getByText("WeChat is connected")).toBeTruthy();
|
|
expect(screen.getByRole("button", { name: "Done" })).toBeTruthy();
|
|
expect(listChannelConnections).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("retries account binding without asking the user to scan again", async () => {
|
|
rs.mocked(connectChannelProvider).mockRejectedValueOnce(
|
|
new Error("unavailable"),
|
|
);
|
|
mount();
|
|
await screen.findByText(
|
|
"Your token is saved, but account binding could not start. Try again.",
|
|
);
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Generate binding code" }),
|
|
);
|
|
expect(await screen.findByText("/connect demo")).toBeTruthy();
|
|
await waitFor(() => expect(connectChannelProvider).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
it("offers a new binding code when the old one expires", async () => {
|
|
rs.mocked(connectChannelProvider).mockResolvedValueOnce({
|
|
provider: "wechat",
|
|
mode: "binding_code",
|
|
code: "demo",
|
|
instruction: "Send /connect demo",
|
|
expires_in: 0.05,
|
|
});
|
|
mount();
|
|
await screen.findByText(
|
|
"This binding code has expired. Generate a new one; no need to scan again.",
|
|
);
|
|
expect(screen.queryByText("/connect demo")).toBeNull();
|
|
expect(
|
|
screen.getByRole("button", { name: "Generate binding code" }),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it("keeps the original command deadline across rescans and replaces it only after expiry", async () => {
|
|
rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
|
mount(false, { code: "already-on-phone", expiresAt: Date.now() + 5000 });
|
|
await act(() => rs.advanceTimersByTimeAsync(1));
|
|
expect(screen.getByText("/connect already-on-phone")).toBeTruthy();
|
|
expect(connectChannelProvider).not.toHaveBeenCalled();
|
|
await act(() => rs.advanceTimersByTimeAsync(5000));
|
|
expect(screen.queryByText("/connect already-on-phone")).toBeNull();
|
|
expect(screen.getByRole("button", { name: "Scan again" })).toBeTruthy();
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Generate binding code" }),
|
|
);
|
|
await act(() => rs.advanceTimersByTimeAsync(1));
|
|
expect(screen.getByText("/connect demo")).toBeTruthy();
|
|
expect(connectChannelProvider).toHaveBeenCalledTimes(1);
|
|
});
|