564 lines
19 KiB
TypeScript
564 lines
19 KiB
TypeScript
import { createRef, StrictMode, type ReactNode } from "react";
|
||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||
import { ToastProvider } from "@/components/toast-provider";
|
||
import { StateProvider } from "@/components/state-provider";
|
||
import {
|
||
MAX_FILES,
|
||
MAX_FILE_SIZE,
|
||
MAX_TOTAL_SIZE,
|
||
processFile,
|
||
} from "@/components/task/chat/file-attachment";
|
||
import { formatBytes } from "@/lib/utils/format-bytes";
|
||
import { TaskFormInputs } from "./task-create-dialog-selectors";
|
||
import { TaskCreateLaunchPreviewToggle } from "./task-create-dialog-launch-preview-control";
|
||
import type { TaskFormInputsHandle } from "./task-create-dialog-types";
|
||
import type { TaskCreateLaunchPreview } from "./task-create-dialog-launch-preview";
|
||
import type { PluginComposerSlotProps } from "@/lib/plugins/types";
|
||
|
||
const TOAST_MESSAGE_TEST_ID = "toast-message";
|
||
const LAUNCH_PREVIEW_TOGGLE_TEST_ID = "task-create-launch-preview-toggle";
|
||
const DESCRIPTION_INPUT_TEST_ID = "task-description-input";
|
||
const ORIGINAL_PROMPT = "keep this prompt";
|
||
|
||
vi.mock("@/components/task/chat/file-attachment", async () => {
|
||
const actual = await vi.importActual<typeof import("@/components/task/chat/file-attachment")>(
|
||
"@/components/task/chat/file-attachment",
|
||
);
|
||
return { ...actual, processFile: vi.fn() };
|
||
});
|
||
|
||
// Captures the slot props TaskFormInputs hands its plugin composer action,
|
||
// which is the only way text now reaches the description programmatically.
|
||
const pluginSlotCalls: PluginComposerSlotProps[] = [];
|
||
const mentionMocks = vi.hoisted(() => ({ handleKeyDown: vi.fn() }));
|
||
|
||
vi.mock("@/components/plugins/plugin-slot", () => ({
|
||
PluginSlot: ({ slotProps }: { slotProps: PluginComposerSlotProps }) => {
|
||
pluginSlotCalls.push(slotProps);
|
||
return null;
|
||
},
|
||
}));
|
||
|
||
// Inert mention popover — the real hook installs a `keydown` listener that
|
||
// drains React's event queue across re-renders and adds noise to assertions.
|
||
vi.mock("@/hooks/use-task-create-prompt-mention", () => {
|
||
const useMention = ({ onChange }: { onChange?: (value: string) => void } = {}) => ({
|
||
isOpen: false,
|
||
isLoading: false,
|
||
position: null,
|
||
items: [],
|
||
query: "",
|
||
selectedIndex: 0,
|
||
handleChange: (value: string) => onChange?.(value),
|
||
handleKeyDown: mentionMocks.handleKeyDown,
|
||
handleSelect: () => {},
|
||
closeMenu: () => {},
|
||
setSelectedIndex: () => {},
|
||
});
|
||
return {
|
||
useTaskCreatePromptMention: useMention,
|
||
useTaskCreatePromptMentionForInput: useMention,
|
||
};
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
pluginSlotCalls.length = 0;
|
||
mentionMocks.handleKeyDown.mockReset();
|
||
vi.restoreAllMocks();
|
||
vi.mocked(processFile).mockReset();
|
||
});
|
||
|
||
function lastPluginSlotProps(): PluginComposerSlotProps {
|
||
const last = pluginSlotCalls.at(-1);
|
||
if (!last) throw new Error("PluginSlot was not rendered");
|
||
return last;
|
||
}
|
||
|
||
function Wrapper({ children }: { children: ReactNode }) {
|
||
return (
|
||
<ToastProvider>
|
||
<TooltipProvider>{children}</TooltipProvider>
|
||
</ToastProvider>
|
||
);
|
||
}
|
||
|
||
function RichPromptWrapper({ children }: { children: ReactNode }) {
|
||
return (
|
||
<StateProvider initialState={{ prompts: { items: [], loaded: true, loading: false } }}>
|
||
<Wrapper>{children}</Wrapper>
|
||
</StateProvider>
|
||
);
|
||
}
|
||
|
||
function renderTaskFormInputs(
|
||
initial: string,
|
||
strict = false,
|
||
launchPreview: TaskCreateLaunchPreview | null = null,
|
||
promptReferencesEnabled = false,
|
||
onComposerSubmit?: () => boolean | Promise<boolean>,
|
||
) {
|
||
const ref = createRef<TaskFormInputsHandle>();
|
||
const form = (
|
||
<TaskFormInputs
|
||
isSessionMode={false}
|
||
autoFocus={false}
|
||
initialDescription={initial}
|
||
promptReferencesEnabled={promptReferencesEnabled}
|
||
onComposerSubmit={onComposerSubmit}
|
||
onDescriptionChange={() => {}}
|
||
onKeyDown={() => {}}
|
||
descriptionValueRef={ref}
|
||
launchPreview={launchPreview}
|
||
/>
|
||
);
|
||
const wrapper = promptReferencesEnabled ? RichPromptWrapper : Wrapper;
|
||
const utils = render(strict ? <StrictMode>{form}</StrictMode> : form, { wrapper });
|
||
const textarea = screen.getByTestId(DESCRIPTION_INPUT_TEST_ID) as HTMLTextAreaElement;
|
||
return { ...utils, textarea, ref };
|
||
}
|
||
|
||
describe("TaskFormInputs launch prompt preview", () => {
|
||
const launchPreview: TaskCreateLaunchPreview = {
|
||
stepId: "step-1",
|
||
stepName: "In Progress",
|
||
stepPrompt: "Run {{task_prompt}} for {task_id}",
|
||
};
|
||
|
||
it("toggles a composed read-only preview without changing the draft", () => {
|
||
const { textarea, ref } = renderTaskFormInputs(ORIGINAL_PROMPT, false, launchPreview);
|
||
const toggle = screen.getByTestId(LAUNCH_PREVIEW_TOGGLE_TEST_ID);
|
||
|
||
expect(toggle.getAttribute("aria-pressed")).toBe("false");
|
||
fireEvent.click(toggle);
|
||
|
||
expect(screen.queryByTestId(DESCRIPTION_INPUT_TEST_ID)).toBeNull();
|
||
expect(screen.getByTestId("task-create-launch-preview-content").textContent).toContain(
|
||
`Run ${ORIGINAL_PROMPT} for {task_id}`,
|
||
);
|
||
expect(ref.current?.getValue()).toBe(ORIGINAL_PROMPT);
|
||
expect(toggle.getAttribute("aria-pressed")).toBe("true");
|
||
|
||
fireEvent.click(toggle);
|
||
expect((screen.getByTestId(DESCRIPTION_INPUT_TEST_ID) as HTMLTextAreaElement).value).toBe(
|
||
ORIGINAL_PROMPT,
|
||
);
|
||
expect(textarea).not.toBe(screen.getByTestId(DESCRIPTION_INPUT_TEST_ID));
|
||
expect(ref.current?.getValue()).toBe(ORIGINAL_PROMPT);
|
||
});
|
||
|
||
it("does not render a preview toggle without a nonempty step prompt", () => {
|
||
renderTaskFormInputs(ORIGINAL_PROMPT, false, {
|
||
...launchPreview,
|
||
stepPrompt: " ",
|
||
});
|
||
|
||
expect(screen.queryByTestId(LAUNCH_PREVIEW_TOGGLE_TEST_ID)).toBeNull();
|
||
});
|
||
|
||
it("returns to the editor when the launch preview model disappears", () => {
|
||
const ref = createRef<TaskFormInputsHandle>();
|
||
const renderForm = (preview: TaskCreateLaunchPreview | null) => (
|
||
<TaskFormInputs
|
||
isSessionMode={false}
|
||
autoFocus={false}
|
||
initialDescription={ORIGINAL_PROMPT}
|
||
onDescriptionChange={() => {}}
|
||
onKeyDown={() => {}}
|
||
descriptionValueRef={ref}
|
||
launchPreview={preview}
|
||
/>
|
||
);
|
||
const view = render(renderForm(launchPreview), { wrapper: Wrapper });
|
||
|
||
fireEvent.click(screen.getByTestId(LAUNCH_PREVIEW_TOGGLE_TEST_ID));
|
||
view.rerender(renderForm(null));
|
||
|
||
expect(screen.getByTestId(DESCRIPTION_INPUT_TEST_ID)).toBeTruthy();
|
||
expect(screen.queryByTestId("task-create-launch-preview-content")).toBeNull();
|
||
expect(screen.queryByTestId(LAUNCH_PREVIEW_TOGGLE_TEST_ID)).toBeNull();
|
||
expect((screen.getByTestId(DESCRIPTION_INPUT_TEST_ID) as HTMLTextAreaElement).value).toBe(
|
||
ORIGINAL_PROMPT,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("TaskCreateLaunchPreviewToggle", () => {
|
||
it("keeps a disabled toggle tooltip trigger focusable", () => {
|
||
render(
|
||
<TaskCreateLaunchPreviewToggle
|
||
active={false}
|
||
disabled
|
||
stepName="In Progress"
|
||
onToggle={() => undefined}
|
||
/>,
|
||
{ wrapper: Wrapper },
|
||
);
|
||
|
||
const toggle = screen.getByTestId(LAUNCH_PREVIEW_TOGGLE_TEST_ID);
|
||
expect(toggle.getAttribute("disabled")).not.toBeNull();
|
||
expect(toggle.getAttribute("aria-label")).toBe(
|
||
"Preview launch prompt with workflow step prompt: In Progress",
|
||
);
|
||
expect(toggle.parentElement?.getAttribute("tabindex")).toBe("0");
|
||
expect(toggle.parentElement?.classList.contains("inline-flex")).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("TaskFormInputs mention keyboard routing", () => {
|
||
it("handles Escape in capture before a target listener stops the bubble phase", () => {
|
||
const { textarea } = renderTaskFormInputs("");
|
||
const stopAtTarget = (event: KeyboardEvent) => event.stopPropagation();
|
||
textarea.addEventListener("keydown", stopAtTarget);
|
||
|
||
fireEvent.keyDown(textarea, { key: "Escape", bubbles: true, cancelable: true });
|
||
|
||
textarea.removeEventListener("keydown", stopAtTarget);
|
||
expect(mentionMocks.handleKeyDown).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
describe("TaskFormInputs plugin composer action — rendering", () => {
|
||
it("renders the composer slot in task-create mode", () => {
|
||
renderTaskFormInputs("");
|
||
expect(lastPluginSlotProps().surface).toBe("task-create");
|
||
});
|
||
|
||
it("renders the composer slot in session mode", () => {
|
||
const ref = createRef<TaskFormInputsHandle>();
|
||
render(
|
||
<TaskFormInputs
|
||
isSessionMode
|
||
autoFocus={false}
|
||
initialDescription=""
|
||
onDescriptionChange={() => {}}
|
||
onKeyDown={() => {}}
|
||
descriptionValueRef={ref}
|
||
/>,
|
||
{ wrapper: Wrapper },
|
||
);
|
||
expect(lastPluginSlotProps().surface).toBe("new-session");
|
||
});
|
||
|
||
it("reports the form's disabled state to the plugin", () => {
|
||
const ref = createRef<TaskFormInputsHandle>();
|
||
render(
|
||
<TaskFormInputs
|
||
isSessionMode={false}
|
||
autoFocus={false}
|
||
initialDescription=""
|
||
onDescriptionChange={() => {}}
|
||
onKeyDown={() => {}}
|
||
descriptionValueRef={ref}
|
||
disabled
|
||
/>,
|
||
{ wrapper: Wrapper },
|
||
);
|
||
|
||
expect(lastPluginSlotProps().disabled).toBe(true);
|
||
expect(lastPluginSlotProps().submittable).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("TaskFormInputs plugin composer submission", () => {
|
||
it("reports blocked when the native creation path rejects submission", async () => {
|
||
const ref = createRef<TaskFormInputsHandle>();
|
||
render(
|
||
<TaskFormInputs
|
||
isSessionMode={false}
|
||
autoFocus={false}
|
||
initialDescription="prompt"
|
||
onDescriptionChange={() => {}}
|
||
onKeyDown={() => {}}
|
||
descriptionValueRef={ref}
|
||
onComposerSubmit={() => false}
|
||
/>,
|
||
{ wrapper: Wrapper },
|
||
);
|
||
|
||
await expect(lastPluginSlotProps().composer.submit()).resolves.toEqual({ status: "blocked" });
|
||
});
|
||
|
||
it("reports submitted only when the native creation path accepts submission", async () => {
|
||
const ref = createRef<TaskFormInputsHandle>();
|
||
render(
|
||
<TaskFormInputs
|
||
isSessionMode={false}
|
||
autoFocus={false}
|
||
initialDescription="prompt"
|
||
onDescriptionChange={() => {}}
|
||
onKeyDown={() => {}}
|
||
descriptionValueRef={ref}
|
||
onComposerSubmit={() => true}
|
||
/>,
|
||
{ wrapper: Wrapper },
|
||
);
|
||
|
||
await expect(lastPluginSlotProps().composer.submit()).resolves.toEqual({ status: "submitted" });
|
||
});
|
||
});
|
||
|
||
describe("TaskFormInputs plugin composer chained calls", () => {
|
||
it("submits a transcript inserted in the same callback, before React re-renders", async () => {
|
||
// The shape a dictation plugin actually has: insert the transcript and
|
||
// submit it without yielding. The gate must read the synchronous value,
|
||
// not the render snapshot, or an empty composer stays "blocked" forever.
|
||
const onComposerSubmit = vi.fn(() => true);
|
||
const ref = createRef<TaskFormInputsHandle>();
|
||
render(
|
||
<TaskFormInputs
|
||
isSessionMode={false}
|
||
autoFocus={false}
|
||
initialDescription=""
|
||
onDescriptionChange={() => {}}
|
||
onKeyDown={() => {}}
|
||
descriptionValueRef={ref}
|
||
onComposerSubmit={onComposerSubmit}
|
||
/>,
|
||
{ wrapper: Wrapper },
|
||
);
|
||
|
||
const composer = lastPluginSlotProps().composer;
|
||
let result: Awaited<ReturnType<typeof composer.submit>> | undefined;
|
||
await act(async () => {
|
||
composer.insertText("dictated prompt");
|
||
result = await composer.submit();
|
||
});
|
||
|
||
expect(result).toEqual({ status: "submitted" });
|
||
expect(onComposerSubmit).toHaveBeenCalledTimes(1);
|
||
expect(ref.current?.getValue()).toBe("dictated prompt");
|
||
});
|
||
|
||
it("keeps both transcripts when a plugin inserts twice in one callback", () => {
|
||
const { textarea } = renderTaskFormInputs("");
|
||
|
||
act(() => {
|
||
lastPluginSlotProps().composer.insertText("first");
|
||
lastPluginSlotProps().composer.insertText("second");
|
||
});
|
||
|
||
expect(textarea.value).toBe("first second");
|
||
});
|
||
|
||
it("supports rich prompt references for literal plugin insertion and submit", async () => {
|
||
const onComposerSubmit = vi.fn(() => true);
|
||
const { ref } = renderTaskFormInputs("", false, null, true, onComposerSubmit);
|
||
const composer = lastPluginSlotProps().composer;
|
||
const first = "<p>hello</p> &\n first";
|
||
let result: Awaited<ReturnType<typeof composer.submit>> | undefined;
|
||
|
||
await act(async () => {
|
||
composer.insertText(first);
|
||
composer.insertText("tail");
|
||
result = await composer.submit();
|
||
});
|
||
|
||
expect(result).toEqual({ status: "submitted" });
|
||
expect(ref.current?.getValue()).toBe(`${first} tail`);
|
||
expect(onComposerSubmit).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
describe("TaskFormInputs plugin composer action — at-cursor splice", () => {
|
||
it("splices the transcript at the caret with a leading space after a word", () => {
|
||
const { textarea } = renderTaskFormInputs("hello world");
|
||
textarea.focus();
|
||
textarea.setSelectionRange(5, 5);
|
||
|
||
act(() => {
|
||
lastPluginSlotProps().composer.insertText("there");
|
||
});
|
||
|
||
expect(textarea.value).toBe("hello there world");
|
||
expect(textarea.selectionStart).toBe(11);
|
||
expect(textarea.selectionEnd).toBe(11);
|
||
});
|
||
|
||
it("inserts the transcript without a leading space when the caret follows whitespace", () => {
|
||
const { textarea } = renderTaskFormInputs("hello ");
|
||
textarea.focus();
|
||
textarea.setSelectionRange(6, 6);
|
||
|
||
act(() => {
|
||
lastPluginSlotProps().composer.insertText("world");
|
||
});
|
||
|
||
expect(textarea.value).toBe("hello world");
|
||
expect(textarea.selectionStart).toBe(11);
|
||
});
|
||
|
||
it("replaces selected text with the transcript", () => {
|
||
const { textarea } = renderTaskFormInputs("hello world");
|
||
textarea.focus();
|
||
textarea.setSelectionRange(6, 11);
|
||
|
||
act(() => {
|
||
lastPluginSlotProps().composer.insertText("there");
|
||
});
|
||
|
||
expect(textarea.value).toBe("hello there");
|
||
});
|
||
|
||
it("ignores empty / whitespace-only transcripts", () => {
|
||
const { textarea } = renderTaskFormInputs("hello");
|
||
textarea.focus();
|
||
textarea.setSelectionRange(5, 5);
|
||
|
||
act(() => {
|
||
lastPluginSlotProps().composer.insertText(" ");
|
||
});
|
||
|
||
expect(textarea.value).toBe("hello");
|
||
});
|
||
|
||
it("inserts the transcript into a multi-line description at the line caret", () => {
|
||
const { textarea } = renderTaskFormInputs("line one\nline two");
|
||
textarea.focus();
|
||
// Caret right after "line one" on the first line — char-before is "e",
|
||
// non-whitespace, so a leading space is prepended.
|
||
textarea.setSelectionRange(8, 8);
|
||
|
||
act(() => {
|
||
lastPluginSlotProps().composer.insertText("added");
|
||
});
|
||
|
||
expect(textarea.value).toBe("line one added\nline two");
|
||
expect(textarea.selectionStart).toBe(14);
|
||
});
|
||
|
||
it("preserves internal newlines from the transcript", () => {
|
||
const { textarea } = renderTaskFormInputs("");
|
||
textarea.focus();
|
||
textarea.setSelectionRange(0, 0);
|
||
|
||
act(() => {
|
||
lastPluginSlotProps().composer.insertText("first\nsecond");
|
||
});
|
||
|
||
expect(textarea.value).toBe("first\nsecond");
|
||
});
|
||
|
||
it("treats existing tabs / newlines before the caret as whitespace (no extra space)", () => {
|
||
const { textarea } = renderTaskFormInputs("line\n");
|
||
textarea.focus();
|
||
textarea.setSelectionRange(5, 5);
|
||
|
||
act(() => {
|
||
lastPluginSlotProps().composer.insertText("two");
|
||
});
|
||
|
||
expect(textarea.value).toBe("line\ntwo");
|
||
});
|
||
});
|
||
|
||
describe("TaskFormInputs attachment feedback", () => {
|
||
it("shows one count-limit toast when Strict Mode replays an attachment-limit update", async () => {
|
||
vi.mocked(processFile).mockImplementation(async (file) => ({
|
||
id: file.name,
|
||
data: "",
|
||
mimeType: file.type,
|
||
fileName: file.name,
|
||
size: file.size,
|
||
isImage: false,
|
||
deliveryMode: "path",
|
||
}));
|
||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||
const { textarea } = renderTaskFormInputs("", true);
|
||
const files = Array.from(
|
||
{ length: MAX_FILES + 1 },
|
||
(_, index) => new File(["file"], `attachment-${index}.txt`, { type: "text/plain" }),
|
||
);
|
||
|
||
fireEvent.paste(textarea, {
|
||
clipboardData: {
|
||
files,
|
||
items: files.map((file) => ({ kind: "file", type: file.type, getAsFile: () => file })),
|
||
getData: () => "",
|
||
},
|
||
});
|
||
|
||
const warning = await screen.findByTestId(TOAST_MESSAGE_TEST_ID);
|
||
expect(warning.textContent).toContain("Attachment limit reached");
|
||
expect(warning.textContent).toContain(`You can attach up to ${MAX_FILES} files.`);
|
||
expect(screen.getAllByTestId(TOAST_MESSAGE_TEST_ID)).toHaveLength(1);
|
||
expect(warn).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("shows a total-size toast when pasted attachments exceed the aggregate limit", async () => {
|
||
vi.mocked(processFile).mockImplementation(async (file) => ({
|
||
id: file.name,
|
||
data: "",
|
||
mimeType: file.type,
|
||
fileName: file.name,
|
||
size: file.size,
|
||
isImage: false,
|
||
deliveryMode: "path",
|
||
}));
|
||
const { textarea } = renderTaskFormInputs("");
|
||
const fileSize = MAX_TOTAL_SIZE / 3 + 1;
|
||
const files = Array.from({ length: 3 }, (_, index) => {
|
||
const file = new File(["attachment"], `attachment-${index}.txt`, { type: "text/plain" });
|
||
Object.defineProperty(file, "size", { value: fileSize });
|
||
return file;
|
||
});
|
||
|
||
fireEvent.paste(textarea, {
|
||
clipboardData: {
|
||
files,
|
||
items: files.map((file) => ({ kind: "file", type: file.type, getAsFile: () => file })),
|
||
getData: () => "",
|
||
},
|
||
});
|
||
|
||
const warning = await screen.findByTestId(TOAST_MESSAGE_TEST_ID);
|
||
expect(warning.textContent).toContain("Attachment limit reached");
|
||
expect(warning.textContent).toContain(
|
||
`Attachments can total up to ${formatBytes(MAX_TOTAL_SIZE)}.`,
|
||
);
|
||
});
|
||
|
||
it("warns when a pasted image exceeds the attachment limit", async () => {
|
||
const { textarea } = renderTaskFormInputs("");
|
||
const image = new File(["image"], "copied-image.png", { type: "image/png" });
|
||
Object.defineProperty(image, "size", { value: MAX_FILE_SIZE + 1 });
|
||
|
||
fireEvent.paste(textarea, {
|
||
clipboardData: {
|
||
files: [image],
|
||
items: [{ kind: "file", type: image.type, getAsFile: () => image }],
|
||
getData: () => "",
|
||
},
|
||
});
|
||
|
||
const warning = await screen.findByTestId(TOAST_MESSAGE_TEST_ID);
|
||
expect(warning.textContent).toContain("Attachment is too large");
|
||
expect(warning.textContent).toContain(
|
||
`copied-image.png is ${formatBytes(MAX_FILE_SIZE + 1)}. The maximum file size is ${formatBytes(MAX_FILE_SIZE)}.`,
|
||
);
|
||
});
|
||
|
||
it("warns when Chrome exposes a copied image without readable file data", async () => {
|
||
const { textarea } = renderTaskFormInputs("");
|
||
|
||
fireEvent.paste(textarea, {
|
||
clipboardData: {
|
||
files: [],
|
||
items: [{ kind: "string", type: "text/html" }],
|
||
getData: (type: string) =>
|
||
type === "text/html"
|
||
? '<meta charset="utf-8"><img src="https://images.example.test/copied-image.png">'
|
||
: "",
|
||
},
|
||
});
|
||
|
||
const warning = await screen.findByTestId(TOAST_MESSAGE_TEST_ID);
|
||
expect(warning.textContent).toContain("Pasted image couldn’t be attached");
|
||
expect(warning.textContent).toContain(
|
||
"The browser didn’t provide image data. Save the image, then attach the file instead.",
|
||
);
|
||
});
|
||
});
|