Files
paseo/packages/app/src/components/message-input.test.tsx
Mohamed Boudra e313bf5776 chore(lint): hoist test fixture arrays/objects
Clears 14 react-perf/jsx-no-new-* warnings across app test files by
hoisting constant fixtures to module scope or wrapping them in helper
factories so they no longer appear as inline JSX prop values.
2026-04-24 02:52:23 +07:00

395 lines
11 KiB
TypeScript

import React, { createRef } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageInput, type AttachmentMenuItem, type MessageInputRef } from "./message-input";
const EMPTY_ATTACHMENTS: React.ComponentProps<typeof MessageInput>["attachments"] = [];
const EMPTY_ATTACHMENT_MENU_ITEMS: AttachmentMenuItem[] = [];
const FAKE_CONNECTED_CLIENT = { isConnected: true } as never;
const { startDictationMock, cancelDictationMock, confirmDictationMock } = vi.hoisted(() => ({
startDictationMock: vi.fn(),
cancelDictationMock: vi.fn(),
confirmDictationMock: vi.fn(),
}));
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" },
shadow: { md: {} },
colors: {
surface1: "#111",
surface2: "#222",
surface3: "#333",
surface4: "#888",
foreground: "#fff",
foregroundMuted: "#aaa",
popoverForeground: "#fff",
borderAccent: "#444",
accent: "#0a84ff",
accentForeground: "#fff",
destructive: "#ff453a",
palette: {
green: { 500: "#30d158" },
},
},
},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("@/constants/platform", () => ({
isWeb: false,
isNative: true,
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
ArrowUp: createIcon("ArrowUp"),
Mic: createIcon("Mic"),
MicOff: createIcon("MicOff"),
CornerDownLeft: createIcon("CornerDownLeft"),
Plus: createIcon("Plus"),
Square: createIcon("Square"),
};
});
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
Keyframe: class Keyframe {
duration() {
return this;
}
withCallback() {
return this;
}
},
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
useSharedValue: (value: unknown) => ({ value }),
useAnimatedStyle: (factory: () => unknown) => factory(),
withTiming: (value: unknown) => value,
}));
vi.mock("@/hooks/use-dictation", () => ({
useDictation: () => ({
isRecording: false,
isProcessing: false,
partialTranscript: "",
volume: 0,
duration: 0,
error: null,
status: "idle",
startDictation: startDictationMock,
cancelDictation: cancelDictationMock,
confirmDictation: confirmDictationMock,
retryFailedDictation: vi.fn(),
discardFailedDictation: vi.fn(),
}),
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: { sessions: Record<string, unknown> }) => unknown) =>
selector({ sessions: {} }),
}));
vi.mock("@/contexts/voice-context", () => ({
useVoiceOptional: () => null,
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
vi.mock("@/utils/server-info-capabilities", () => ({
resolveVoiceUnavailableMessage: () => null,
}));
vi.mock("@/components/use-web-scrollbar", () => ({
useWebElementScrollbar: () => null,
}));
vi.mock(
"@/hooks/use-web-scrollbar-style",
() => ({
useWebScrollbarStyle: () => undefined,
}),
// @ts-expect-error Vitest accepts virtual mocks at runtime; the app's types omit this overload.
{ virtual: true },
);
vi.mock("@/hooks/use-shortcut-keys", () => ({
useShortcutKeys: () => null,
}));
vi.mock("@/components/ui/shortcut", () => ({
Shortcut: () => null,
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({
asChild,
children,
...props
}: {
asChild?: boolean;
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
} & Record<string, any>) =>
asChild ? (
<>{children}</>
) : (
<button type="button" aria-label={props.accessibilityLabel}>
{typeof children === "function" ? children({ hovered: false }) : children}
</button>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuTrigger: ({
children,
testID,
accessibilityLabel,
}: {
children:
| React.ReactNode
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
testID?: string;
accessibilityLabel?: string;
}) => (
<button type="button" data-testid={testID} aria-label={accessibilityLabel}>
{typeof children === "function"
? children({ hovered: false, pressed: false, open: false })
: children}
</button>
),
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
<div data-testid={testID}>{children}</div>
),
DropdownMenuItem: ({
children,
onSelect,
testID,
disabled,
}: {
children: React.ReactNode;
onSelect?: () => void;
testID?: string;
disabled?: boolean;
}) => (
<button type="button" data-testid={testID} disabled={disabled} onClick={onSelect}>
{children}
</button>
),
}));
vi.mock("./dictation-controls", () => ({
DictationOverlay: () => null,
}));
vi.mock("./realtime-voice-overlay", () => ({
RealtimeVoiceOverlay: () => null,
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
startDictationMock.mockReset();
cancelDictationMock.mockReset();
confirmDictationMock.mockReset();
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
});
interface RenderMessageInputOptions {
value?: string;
submitIcon?: "arrow" | "return";
}
function renderMessageInput(
menuItems: AttachmentMenuItem[],
{ value = "", submitIcon }: RenderMessageInputOptions = {},
) {
act(() => {
root?.render(
<MessageInput
value={value}
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={EMPTY_ATTACHMENTS}
cwd="/repo"
attachmentMenuItems={menuItems}
client={FAKE_CONNECTED_CLIENT}
isAgentRunning={false}
submitIcon={submitIcon}
onQueue={vi.fn()}
/>,
);
});
}
function click(element: Element) {
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
function queryByTestId(testID: string): HTMLElement | null {
return document.querySelector(`[data-testid="${testID}"]`);
}
function queryAllByAriaLabel(label: string): NodeListOf<HTMLElement> {
return document.querySelectorAll(`[aria-label="${label}"]`);
}
describe("MessageInput attachments", () => {
it("renders the Plus attachment button and opens a menu with two attachment items", () => {
const menuItems: AttachmentMenuItem[] = [
{ id: "image", label: "Add image", onSelect: vi.fn() },
{ id: "github", label: "Add issue or PR", onSelect: vi.fn() },
];
renderMessageInput(menuItems);
expect(document.querySelectorAll('[data-icon="Plus"]')).toHaveLength(1);
const attachButton = queryByTestId("message-input-attach-button");
expect(attachButton).not.toBeNull();
click(attachButton!);
expect(queryByTestId("message-input-attachment-menu-item-image")).not.toBeNull();
expect(queryByTestId("message-input-attachment-menu-item-github")).not.toBeNull();
expect(
document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]'),
).toHaveLength(2);
});
it("selecting Attach image invokes the supplied image attachment action", () => {
const attachImage = vi.fn();
renderMessageInput([
{ id: "image", label: "Attach image", onSelect: attachImage },
{ id: "github", label: "Attach GitHub issue or PR", onSelect: vi.fn() },
]);
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-image")!);
expect(attachImage).toHaveBeenCalledTimes(1);
});
it("does not render the old queue button", () => {
renderMessageInput([
{ id: "image", label: "Add image", onSelect: vi.fn() },
{ id: "github", label: "Add issue or PR", onSelect: vi.fn() },
]);
expect(queryAllByAriaLabel("Queue message")).toHaveLength(0);
});
it("uses ArrowUp by default and CornerDownLeft when return submit icon is requested", () => {
renderMessageInput([], { value: "Send this" });
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(1);
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(0);
renderMessageInput([], { value: "Create this", submitIcon: "return" });
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(0);
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(1);
});
});
describe("MessageInput dictation shortcuts", () => {
it("does not poison the dictation toggle when readiness is temporarily false", () => {
const inputRef = createRef<MessageInputRef>();
act(() => {
root?.render(
<MessageInput
ref={inputRef}
value=""
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={EMPTY_ATTACHMENTS}
cwd="/repo"
attachmentMenuItems={EMPTY_ATTACHMENT_MENU_ITEMS}
client={FAKE_CONNECTED_CLIENT}
isAgentRunning={false}
isReadyForDictation={false}
onQueue={vi.fn()}
/>,
);
});
act(() => {
inputRef.current?.runKeyboardAction("dictation-toggle");
});
expect(startDictationMock).not.toHaveBeenCalled();
expect(confirmDictationMock).not.toHaveBeenCalled();
act(() => {
root?.render(
<MessageInput
ref={inputRef}
value=""
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={EMPTY_ATTACHMENTS}
cwd="/repo"
attachmentMenuItems={EMPTY_ATTACHMENT_MENU_ITEMS}
client={FAKE_CONNECTED_CLIENT}
isAgentRunning={false}
isReadyForDictation
onQueue={vi.fn()}
/>,
);
});
act(() => {
inputRef.current?.runKeyboardAction("dictation-toggle");
});
expect(startDictationMock).toHaveBeenCalledTimes(1);
expect(confirmDictationMock).not.toHaveBeenCalled();
});
});