deer-flow/frontend/tests/unit/core/threads/rename-thread.dom.test.tsx
jiaqiang0000 91c7ed4cf5
fix(frontend): keep renamed thread titles in sync (#5045)
* fix(frontend): 同步会话重命名后的标题状态

* chore: 重新触发 PR 自动分流检查

* fix(frontend): address thread title sync review feedback

Signed-off-by: 橘猫 <2622045569@qq.com>

* fix(frontend): fall back when canonical thread title is empty

Signed-off-by: 橘猫 <2622045569@qq.com>

* fix(frontend): fence stale metadata after thread rename

Signed-off-by: 橘猫 <2622045569@qq.com>

* fix(frontend): fence stale thread list responses after rename

Signed-off-by: 橘猫 <2622045569@qq.com>

---------

Signed-off-by: 橘猫 <2622045569@qq.com>
2026-09-01 23:10:54 +08:00

171 lines
4.4 KiB
TypeScript

import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
import {
type InfiniteData,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { act, cleanup, renderHook } from "@testing-library/react";
import type { PropsWithChildren } from "react";
const apiMocks = rs.hoisted(() => ({
updateState: rs.fn(),
}));
rs.mock("@/core/api", () => ({
getAPIClient: () => ({
threads: {
updateState: apiMocks.updateState,
},
}),
}));
import {
INFINITE_THREADS_QUERY_KEY_PREFIX,
useRenameThread,
} from "@/core/threads/hooks";
import { DEFAULT_THREAD_SEARCH_PARAMS } from "@/core/threads/thread-search-query";
import type { AgentThread } from "@/core/threads/types";
const THREAD_ID = "thread-1";
const ORIGINAL_TITLE = "Original title";
const RENAMED_TITLE = "Renamed title";
function makeThread(title: string): AgentThread {
return {
thread_id: THREAD_ID,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
metadata: {},
status: "idle",
values: { title },
} as unknown as AgentThread;
}
function createDelayedResult<T>(value: T) {
let markStarted!: () => void;
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let markCompleted!: () => void;
const completed = new Promise<void>((resolve) => {
markCompleted = resolve;
});
return {
completed,
queryFn: async () => {
markStarted();
await gate;
markCompleted();
return value;
},
release,
started,
};
}
function readSearchTitle(
queryClient: QueryClient,
queryKey: readonly unknown[],
) {
return queryClient.getQueryData<AgentThread[]>(queryKey)?.[0]?.values.title;
}
function readInfiniteSearchTitle(
queryClient: QueryClient,
queryKey: readonly unknown[],
) {
return queryClient.getQueryData<InfiniteData<AgentThread[]>>(queryKey)
?.pages[0]?.[0]?.values.title;
}
test("stale list responses cannot restore the old title after rename", async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const infiniteParams = {
sortBy: "updated_at",
sortOrder: "desc",
select: ["thread_id", "updated_at", "values", "metadata"],
};
const searchKey = [
"threads",
"search",
DEFAULT_THREAD_SEARCH_PARAMS,
] as const;
const infiniteSearchKey = [
...INFINITE_THREADS_QUERY_KEY_PREFIX,
infiniteParams,
] as const;
const originalThread = makeThread(ORIGINAL_TITLE);
const staleSearch = createDelayedResult([originalThread]);
const staleInfiniteSearch = createDelayedResult([originalThread]);
queryClient.setQueryData(searchKey, [originalThread]);
queryClient.setQueryData<InfiniteData<AgentThread[]>>(infiniteSearchKey, {
pages: [[originalThread]],
pageParams: [0],
});
const searchFetch = queryClient
.fetchQuery({
queryKey: searchKey,
queryFn: staleSearch.queryFn,
})
.catch(() => undefined);
const infiniteSearchFetch = queryClient
.fetchInfiniteQuery({
queryKey: infiniteSearchKey,
initialPageParam: 0,
queryFn: staleInfiniteSearch.queryFn,
getNextPageParam: () => undefined,
})
.catch(() => undefined);
await Promise.all([staleSearch.started, staleInfiniteSearch.started]);
apiMocks.updateState.mockResolvedValue(undefined);
const { result } = renderHook(() => useRenameThread(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
threadId: THREAD_ID,
title: RENAMED_TITLE,
});
});
expect(readSearchTitle(queryClient, searchKey)).toBe(RENAMED_TITLE);
expect(readInfiniteSearchTitle(queryClient, infiniteSearchKey)).toBe(
RENAMED_TITLE,
);
staleSearch.release();
staleInfiniteSearch.release();
await Promise.all([staleSearch.completed, staleInfiniteSearch.completed]);
await Promise.all([searchFetch, infiniteSearchFetch]);
expect(readSearchTitle(queryClient, searchKey)).toBe(RENAMED_TITLE);
expect(readInfiniteSearchTitle(queryClient, infiniteSearchKey)).toBe(
RENAMED_TITLE,
);
queryClient.clear();
});
beforeEach(() => {
apiMocks.updateState.mockReset();
});
afterEach(() => {
cleanup();
});