Files
multica/packages/views/layout/help-launcher.test.tsx
Bohan Jiang 449d9887b5 fix(help): wrap server-version row in DropdownMenuGroup to stop Help menu crash (#5458)
Base UI's Menu.GroupLabel (rendered by DropdownMenuLabel) calls
useMenuGroupRootContext(), which throws when it has no Menu.Group ancestor.
The Help launcher rendered the server-version DropdownMenuLabel directly under
the popup, so opening the Help menu on any deployment that returns a non-empty
server_version threw during render. No error boundary sits above the app
sidebar, so the throw unmounted the whole React tree — a black screen with no
error, exactly as reported (MUL-4819).

Wrap the row in DropdownMenuGroup, matching every other DropdownMenuLabel usage
in the codebase. Also harden help-launcher.test.tsx: the old mock flattened the
dropdown to plain divs and silently dropped the Group/GroupLabel contract, which
is why the bug shipped. The mock now mirrors Base UI's throw, so a bare label
fails the test.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 17:46:49 +08:00

84 lines
3.6 KiB
TypeScript

import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { configStore } from "@multica/core/config";
import enLayout from "../locales/en/layout.json";
import { HelpLauncher } from "./help-launcher";
// react-i18next isn't initialised in the views test env, so resolve the
// selector against the real en/layout.json to assert on actual copy.
vi.mock("../i18n", () => ({
useT: () => ({
t: (
sel: (r: typeof enLayout) => string,
vars?: Record<string, string>,
) => {
const template = sel(enLayout);
return vars
? template.replace(/\{\{(\w+)\}\}/g, (_, key) => String(vars[key] ?? ""))
: template;
},
}),
}));
// Follows the app-sidebar.test.tsx convention of flattening the Base UI
// dropdown primitives to plain children so the menu content is always in
// the DOM, instead of exercising the real portal/open-state interaction.
//
// The mock deliberately preserves ONE real invariant: DropdownMenuLabel wraps
// Base UI's Menu.GroupLabel, whose useMenuGroupRootContext() throws when it has
// no Menu.Group ancestor. A plain-<div> mock silently swallowed that contract,
// which is exactly how MUL-4819 shipped — a version row rendered outside a
// DropdownMenuGroup crashed the whole app (no error boundary above the sidebar)
// the moment the Help menu opened. Mirroring the throw here keeps the guard.
// The group context lives inside the factory so it survives vi.mock hoisting.
vi.mock("@multica/ui/components/ui/dropdown-menu", async () => {
const { createContext, useContext } = await import("react");
const GroupContext = createContext(false);
return {
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuItem: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuGroup: ({ children }: { children: ReactNode }) => (
<GroupContext.Provider value={true}>{children}</GroupContext.Provider>
),
DropdownMenuLabel: ({ children }: { children: ReactNode }) => {
if (!useContext(GroupContext)) {
throw new Error(
"Base UI: MenuGroupRootContext is missing. Menu group parts must be used within <Menu.Group>.",
);
}
return <div>{children}</div>;
},
DropdownMenuSeparator: () => null,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
};
});
afterEach(() => {
configStore.getState().setServerVersion("");
});
describe("HelpLauncher", () => {
it("does not show a version row when the server omits it", () => {
render(<HelpLauncher />);
expect(screen.queryByText(/Server version/)).not.toBeInTheDocument();
});
it("shows the server version once /api/config resolves it", () => {
configStore.getState().setServerVersion("1.2.3");
render(<HelpLauncher />);
expect(screen.getByText("Server version 1.2.3")).toBeInTheDocument();
});
// MUL-4819: the version row's DropdownMenuLabel must sit inside a
// DropdownMenuGroup. Rendering it bare made Base UI's Menu.GroupLabel throw
// on open, unmounting the whole app (black screen, no error) because no error
// boundary sits above the sidebar. Rendering here must not throw.
it("renders the version row without a missing-group crash", () => {
configStore.getState().setServerVersion("9.9.9");
expect(() => render(<HelpLauncher />)).not.toThrow();
expect(screen.getByText("Server version 9.9.9")).toBeInTheDocument();
});
});