Merge pull request #13674 from esengine/fix/flaky-frontend-tests

fix(desktop): the branch menu survives the chip settling, and its list is re-read when the root moves
This commit is contained in:
YHH authored and GitHub committed 2026-10-17 18:03:41 -07:00
commit bafb4c0818
6 files changed
+63 -4

No files matched your search

@@ -0,0 +1,33 @@
// @vitest-environment jsdom
import { createRoot } from "react-dom/client";
import { afterEach, expect, it, vi } from "vitest";
import { MockPort } from "../port/mock";
import { WorktreeChip } from "./WorktreeChip";
import type { AgentPort } from "../port/port";
const root = document.createElement("div");
let mounted: ReturnType<typeof createRoot> | null = null;
afterEach(() => { mounted?.unmount(); mounted = null; root.remove(); delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; });
it("keeps open a menu opened between the chip's commit and its mount effects", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false;
document.body.append(root);
const port = new MockPort() as AgentPort;
const sources = { available: false, sourceRoot: "/w", sources: [] };
mounted = createRoot(root);
const clicked = new Promise<void>((resolve) => {
const watch = new MutationObserver(() => {
const button = root.querySelector<HTMLButtonElement>("button.worktree-trigger");
if (!button) return;
watch.disconnect();
button.click();
resolve();
});
watch.observe(root, { childList: true, subtree: true });
});
mounted.render(<WorktreeChip port={port} sources={sources} canCreate={false} selection={null} branch="main" busy={false}
changeCount={0} onSelect={vi.fn()} refresh={async () => sources} onChanged={vi.fn()} onError={vi.fn()} />);
await clicked;
await new Promise((resolve) => setTimeout(resolve, 100));
expect(root.querySelector("button.worktree-trigger")?.getAttribute("aria-expanded")).toBe("true");
});
@@ -43,9 +43,14 @@ export function WorktreeChip({ sources, canCreate, selection, branch, busy, chan
const isolated = workspace?.current === sources.sourceRoot && !!workspace.isolated;
const chosen = sources.sources.find((source) => source.ref === selection?.ref);
const label = chosen?.name ?? selection?.ref ?? branch;
const scope = useRef({ port, root: sources.sourceRoot });
useEffect(() => {
let live = true;
returnFocus.current = false; setWorkspace(null); setOpen(false); setQuery("");
if (scope.current.port !== port || scope.current.root !== sources.sourceRoot) {
scope.current = { port, root: sources.sourceRoot };
returnFocus.current = false; setOpen(false); setQuery("");
}
setWorkspace(null);
void port.workspaces().then((value) => { if (live) setWorkspace(value); }).catch(() => {});
return () => { live = false; };
}, [port, sources.sourceRoot]);
@@ -134,7 +134,9 @@ it("keeps undo after unrelated rerender, supports a refused undo retry and repea
it("allows explicit dismissal without invoking undo", async () => {
const h = await open(); await restore();
await screen.findByRole("button", { name: "撤销这次回退" });
fireEvent.click(screen.getByRole("button", { name: "关闭回退提示" }));
const close = screen.getByRole<HTMLButtonElement>("button", { name: "关闭回退提示" });
await waitFor(() => expect(close.disabled).toBe(false));
fireEvent.click(close);
expect(screen.queryByText("已还原 1 个文件")).toBeNull(); expect(h.undo).not.toHaveBeenCalled();
});
@@ -22,3 +22,16 @@ it("releases the switch state on port change and ignores the old completion", as
expect(switched).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
});
it("keeps the list a reader asked for when the root settles while it is being read", async () => {
const port = new MockPort();
let land!: () => void;
const branches = port.branches.bind(port);
vi.spyOn(port, "branches").mockImplementation(() => new Promise((done) => { land = () => done(branches()); }) as ReturnType<typeof branches>);
const { result, rerender } = renderHook(({ root }) => useWorkspaceBranches(port, vi.fn(), undefined, vi.fn(), root), { initialProps: { root: "/a" } });
act(() => result.current.loadBranches());
rerender({ root: "/b" });
await act(async () => { land(); await Promise.resolve(); });
expect(result.current.reading).toBe("ready");
expect(result.current.locals.map((b) => b.name)).toContain("studio");
});
@@ -6,12 +6,16 @@ export function useWorkspaceBranches(port: AgentPort, onChanged: () => void, onS
const [moving, setMoving] = useState<string | null>(null);
const [reading, setReading] = useState<"idle" | "loading" | "ready" | "failed">("idle");
const generation = useRef(0);
useEffect(() => { generation.current++; setLocals([]); setMoving(null); setReading("idle"); return () => { generation.current++; }; }, [port, root]);
const asked = useRef(false);
const load = useRef<() => void>(() => {});
useEffect(() => { generation.current++; setLocals([]); setMoving(null); setReading("idle"); if (asked.current) load.current(); return () => { generation.current++; }; }, [port, root]);
const loadBranches = useCallback(() => {
const current = generation.current;
asked.current = true;
setReading("loading");
void port.branches().then((result) => { if (current === generation.current) { setLocals(result.branches); setReading("ready"); } }).catch((error) => { if (current === generation.current) { setReading("failed"); onError(error); } });
void port.branches().then((result) => { if (current === generation.current) { asked.current = false; setLocals(result.branches); setReading("ready"); } }).catch((error) => { if (current === generation.current) { asked.current = false; setReading("failed"); onError(error); } });
}, [port, onError, root]);
load.current = loadBranches;
const switchTo = (name: string) => {
if (moving !== null) return;
const current = generation.current;
@@ -0,0 +1,2 @@
- 修复:刚进入窗格就打开分支菜单时,菜单会被自己的挂载收尾关掉,或一直停在加载中。关联 #13674。
- Fixed: Opening the branch menu just as a pane settles no longer shuts it or leaves it loading forever. Refs #13674.