Files
paperclip/ui/src/components/MarkdownEditor.test.tsx
T
Dotta d734bd43d1 [codex] Roll up May 17 branch changes (#6210)
## Thinking Path

> - Paperclip is the control plane for autonomous AI companies, so agent
work needs visible ownership, recovery, and operator controls.
> - This local branch had accumulated several related control-plane
reliability and operator-experience fixes across recovery actions,
watchdog folding, model-profile defaults, mentions, markdown editing,
plugin launchers, and small UI polish.
> - The branch needed to be converted into a PR against the current
`origin/master` without losing dirty work or including lockfile/workflow
churn.
> - The safest standalone shape is a single rollup PR because the
recovery/server/UI files overlap heavily across the local commits and
splitting would create avoidable conflicts.
> - This pull request replays the local branch onto latest
`origin/master`, preserves the uncommitted work as logical commits, and
adds a Zod 4 validator compatibility fix found during verification.
> - The benefit is that the May 17 local branch can be reviewed and
merged as one coherent, conflict-free branch under the 100-file Greptile
limit.

## What Changed

- Rebased the local May 17 branch work onto current `origin/master` in a
dedicated worktree.
- Preserved and committed previously dirty changes for recovery retry
handling, plugin/sidebar launcher polish, and `.herenow` ignores.
- Added recovery-action behavior for returning source issues to `todo`
when retrying source-scoped recovery.
- Included the existing local recovery/liveness/watchdog fold, Codex
cheap-profile, markdown/mention, duplicate-agent, and UI polish commits
from the branch.
- Normalized shared validator `z.record(...)` schemas to explicit
string-key records for Zod 4 compatibility.
- Confirmed the PR has no `pnpm-lock.yaml` or `.github/workflows/*`
changes and stays below the 100-file Greptile limit.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `npm run install` in
`node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3` to build the
local native sqlite3 binding after installing with scripts disabled
- `pnpm exec vitest run packages/shared/src/validators/issue.test.ts
packages/shared/src/project-mentions.test.ts
packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/heartbeat-model-profile.test.ts
server/src/__tests__/issue-recovery-actions.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
server/src/__tests__/plugin-local-folders.test.ts
ui/src/components/IssueRecoveryActionCard.test.tsx
ui/src/components/Sidebar.test.tsx
ui/src/components/SidebarAccountMenu.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/components/MarkdownEditor.test.tsx
ui/src/components/MarkdownBody.test.tsx
ui/src/lib/duplicate-agent-payload.test.ts
ui/src/pages/Routines.test.tsx`
- First pass: 13 files passed with 201 passing tests; 3 server files
failed before sqlite3 native binding was built.
- After rebuilding sqlite3:
`server/src/__tests__/heartbeat-model-profile.test.ts`,
`server/src/__tests__/issue-recovery-actions.test.ts`, and
`server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts`
passed/loaded; embedded Postgres tests were skipped by the local host
guard.
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/adapter-utils typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`

## Risks

- Medium risk: this is a broad rollup PR across recovery semantics,
server tests, shared validators, and UI surfaces.
- Some embedded Postgres tests skipped locally due the host guard, so CI
should provide the stronger database-backed signal.
- UI changes were covered by component tests, but no browser screenshot
was captured in this PR creation pass.
- This branch may overlap with existing recovery/liveness PR work; merge
this PR independently or restack/close overlapping branches rather than
merging duplicate implementations together.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent, tool-enabled local repository
and GitHub workflow, medium reasoning effort.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-17 17:15:06 -05:00

796 lines
23 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildProjectMentionHref, buildRoutineMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
import {
computeMentionMenuPosition,
findClosestAutocompleteAnchor,
findMentionMatch,
isSameAutocompleteSession,
MarkdownEditor,
placeCaretAfterMentionAnchor,
shouldAcceptAutocompleteKey,
} from "./MarkdownEditor";
const mdxEditorMockState = vi.hoisted(() => ({
emitMountEmptyReset: false,
emitMountParseError: false,
emitMountSilentEmptyState: false,
markdownValues: [] as string[],
suppressHtmlProcessingValues: [] as boolean[],
}));
function containsHtmlLikeTag(markdown: string) {
return /<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s[^>]*)?\/?>/.test(markdown);
}
vi.mock("@mdxeditor/editor", async () => {
const React = await import("react");
function setForwardedRef<T>(ref: React.ForwardedRef<T | null>, value: T | null) {
if (typeof ref === "function") {
ref(value);
return;
}
if (ref) {
(ref as React.MutableRefObject<T | null>).current = value;
}
}
const MDXEditor = React.forwardRef(function MockMDXEditor(
{
markdown,
placeholder,
onChange,
onError,
className,
suppressHtmlProcessing,
}: {
markdown: string;
placeholder?: string;
onChange?: (value: string) => void;
onError?: (error: unknown) => void;
suppressHtmlProcessing?: boolean;
className?: string;
},
forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>,
) {
mdxEditorMockState.markdownValues.push(markdown);
mdxEditorMockState.suppressHtmlProcessingValues.push(Boolean(suppressHtmlProcessing));
const [content, setContent] = React.useState(markdown);
const editableRef = React.useRef<HTMLDivElement>(null);
const handle = React.useMemo(() => ({
setMarkdown: (value: string) => setContent(value),
focus: () => editableRef.current?.focus(),
}), []);
React.useEffect(() => {
if (!suppressHtmlProcessing && containsHtmlLikeTag(markdown)) {
setContent("");
onError?.({
error: "Error parsing markdown: HTML-like formatting requires suppressHtmlProcessing",
source: markdown,
});
return;
}
setContent(markdown);
}, [markdown, onError, suppressHtmlProcessing]);
React.useEffect(() => {
setForwardedRef(forwardedRef, null);
const timer = window.setTimeout(() => {
setForwardedRef(forwardedRef, handle);
if (mdxEditorMockState.emitMountEmptyReset) {
setContent("");
onChange?.("");
}
if (mdxEditorMockState.emitMountSilentEmptyState) {
setContent("");
}
if (mdxEditorMockState.emitMountParseError) {
setContent("");
onError?.({
error: "Unsupported markdown syntax",
source: markdown,
});
}
}, 0);
return () => {
window.clearTimeout(timer);
setForwardedRef(forwardedRef, null);
};
}, []);
return (
<div
ref={editableRef}
data-testid="mdx-editor"
className={className}
contentEditable
suppressContentEditableWarning
>
{content || placeholder || ""}
</div>
);
});
return {
CodeMirrorEditor: () => null,
MDXEditor,
codeBlockPlugin: () => ({}),
codeMirrorPlugin: () => ({}),
createRootEditorSubscription$: Symbol("createRootEditorSubscription$"),
headingsPlugin: () => ({}),
imagePlugin: () => ({}),
linkDialogPlugin: () => ({}),
linkPlugin: () => ({}),
listsPlugin: () => ({}),
markdownShortcutPlugin: () => ({}),
quotePlugin: () => ({}),
realmPlugin: (plugin: unknown) => plugin,
tablePlugin: () => ({}),
thematicBreakPlugin: () => ({}),
};
});
vi.mock("../lib/mention-deletion", () => ({
mentionDeletionPlugin: () => ({}),
}));
vi.mock("../lib/paste-normalization", () => ({
pasteNormalizationPlugin: () => ({}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
function createFileDragEvent(type: string) {
const event = new Event(type, { bubbles: true, cancelable: true }) as Event & {
dataTransfer: { types: string[]; files: File[]; dropEffect?: string };
};
event.dataTransfer = {
types: ["Files"],
files: [],
};
return event;
}
describe("MarkdownEditor", () => {
let container: HTMLDivElement;
let originalRangeRect: typeof Range.prototype.getBoundingClientRect;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
originalRangeRect = Range.prototype.getBoundingClientRect;
Range.prototype.getBoundingClientRect = () => ({
x: 32,
y: 24,
width: 12,
height: 18,
top: 24,
right: 44,
bottom: 42,
left: 32,
toJSON: () => ({}),
});
});
afterEach(() => {
container.remove();
Range.prototype.getBoundingClientRect = originalRangeRect;
vi.clearAllMocks();
mdxEditorMockState.emitMountEmptyReset = false;
mdxEditorMockState.emitMountParseError = false;
mdxEditorMockState.emitMountSilentEmptyState = false;
mdxEditorMockState.markdownValues = [];
mdxEditorMockState.suppressHtmlProcessingValues = [];
});
it("applies async external value updates once the editor ref becomes ready", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value=""
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await act(async () => {
root.render(
<MarkdownEditor
value="Loaded plan body"
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await flush();
expect(container.textContent).toContain("Loaded plan body");
await act(async () => {
root.unmount();
});
});
it("keeps the external value when the unfocused editor emits an empty mount reset", async () => {
mdxEditorMockState.emitMountEmptyReset = true;
const handleChange = vi.fn();
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value="Loaded plan body"
onChange={handleChange}
placeholder="Markdown body"
/>,
);
});
await flush();
expect(container.textContent).toContain("Loaded plan body");
expect(handleChange).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
it("converts advisory-style html image tags to markdown image syntax before mounting the editor", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value={`Before\n\n<img width="10" height="10" alt="image" src="https://example.com/test.png" />\n\nAfter`}
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await flush();
expect(mdxEditorMockState.markdownValues.at(-1)).toContain("![image](https://example.com/test.png)");
expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain("<img");
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(true);
expect(container.textContent).toContain("Before");
expect(container.textContent).toContain("After");
await act(async () => {
root.unmount();
});
});
it("keeps arbitrary HTML-like tags in the rich editor instead of falling back to raw source", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value={'<section data-source="paste">\n## My take\n\n<p>Benchmark notes</p>\n</section>'}
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await flush();
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(true);
expect(container.querySelector("textarea")).toBeNull();
expect(container.textContent).toContain("Benchmark notes");
expect(container.textContent).not.toContain("Rich editor unavailable for this markdown");
await act(async () => {
root.unmount();
});
});
it("keeps scriptable pasted HTML inert in the rich editor", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value={'<script>fetch("/api/secrets")</script>\n<iframe src="https://example.com"></iframe>\n<p onclick="steal()">Plain text</p>'}
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await flush();
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(true);
expect(container.querySelector("textarea")).toBeNull();
expect(container.querySelector("script, iframe, p[onclick]")).toBeNull();
expect(container.textContent).toContain('fetch("/api/secrets")');
expect(container.textContent).toContain("Plain text");
await act(async () => {
root.unmount();
});
});
it("falls back to a raw textarea when the rich parser rejects the markdown", async () => {
mdxEditorMockState.emitMountParseError = true;
const handleChange = vi.fn();
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value="Affected versions: <= v0.3.1"
onChange={handleChange}
placeholder="Markdown body"
/>,
);
});
await flush();
await vi.waitFor(() => {
expect(container.querySelector("textarea")).not.toBeNull();
});
const textarea = container.querySelector("textarea");
expect(textarea).not.toBeNull();
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
expect(container.textContent).toContain("Rich editor unavailable for this markdown");
expect(handleChange).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
it("falls back to a raw textarea when the rich editor mounts into the placeholder without callbacks", async () => {
mdxEditorMockState.emitMountSilentEmptyState = true;
const handleChange = vi.fn();
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value="Affected versions: <= v0.3.1"
onChange={handleChange}
placeholder="Add a description..."
/>,
);
});
await flush();
await vi.waitFor(() => {
expect(container.querySelector("textarea")).not.toBeNull();
});
const textarea = container.querySelector("textarea");
expect(textarea).not.toBeNull();
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
expect(container.textContent).toContain("Rich editor unavailable for this markdown");
expect(handleChange).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
it("shows the editor-scoped dropzone by default when files are dragged over it", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value=""
onChange={() => {}}
placeholder="Markdown body"
imageUploadHandler={async () => "https://example.com/image.png"}
/>,
);
});
await flush();
const scope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement as HTMLDivElement | null;
expect(scope).not.toBeNull();
act(() => {
scope?.dispatchEvent(createFileDragEvent("dragenter"));
});
expect(scope?.className).toContain("ring-1");
expect(container.textContent).toContain("Drop image to upload");
act(() => {
scope?.dispatchEvent(createFileDragEvent("dragleave"));
});
expect(scope?.className).not.toContain("ring-1");
await act(async () => {
root.unmount();
});
});
it("defers file-drop visuals to a parent container when requested", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value=""
onChange={() => {}}
placeholder="Markdown body"
imageUploadHandler={async () => "https://example.com/image.png"}
fileDropTarget="parent"
/>,
);
});
await flush();
const scope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement as HTMLDivElement | null;
expect(scope).not.toBeNull();
act(() => {
scope?.dispatchEvent(createFileDragEvent("dragenter"));
});
expect(scope?.className).not.toContain("ring-1");
expect(container.textContent).not.toContain("Drop image to upload");
await act(async () => {
root.unmount();
});
});
it("does not show the raw fallback while image-only markdown is settling", async () => {
mdxEditorMockState.emitMountSilentEmptyState = true;
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value="![Screenshot](/api/attachments/image/content)"
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await flush();
await flush();
expect(container.querySelector("textarea")).toBeNull();
expect(container.textContent).not.toContain("Rich editor unavailable for this markdown");
await act(async () => {
root.unmount();
});
});
it("places the menu top on the caret line and offsets the left a space-width past the caret", () => {
expect(
computeMentionMenuPosition(
{ viewportTop: 100, viewportBottom: 118, viewportLeft: 240 },
{ offsetLeft: 0, offsetTop: 0, width: 800, height: 600 },
),
).toEqual({
top: 100,
left: 250,
});
});
it("applies visual viewport offsets when present", () => {
expect(
computeMentionMenuPosition(
{ viewportTop: 20, viewportBottom: 38, viewportLeft: 120 },
{ offsetLeft: 24, offsetTop: 320, width: 320, height: 260 },
),
).toEqual({
top: 340,
left: 154,
});
});
it("clamps the mention menu back into view near the viewport edges", () => {
expect(
computeMentionMenuPosition(
{ viewportTop: 260, viewportBottom: 278, viewportLeft: 240 },
{ offsetLeft: 0, offsetTop: 0, width: 280, height: 220 },
),
).toEqual({
top: 12,
left: 92,
});
});
it("flips the menu above the caret line when it would overflow below", () => {
expect(
computeMentionMenuPosition(
{ viewportTop: 560, viewportBottom: 580, viewportLeft: 200 },
{ offsetLeft: 0, offsetTop: 0, width: 800, height: 600 },
),
).toEqual({
top: 372,
left: 210,
});
});
it("keeps a short mention menu on the same line when it fits below the caret", () => {
expect(
computeMentionMenuPosition(
{ viewportTop: 160, viewportBottom: 178, viewportLeft: 120 },
{ offsetLeft: 0, offsetTop: 0, width: 320, height: 220 },
{ width: 188, height: 42 },
),
).toEqual({
top: 160,
left: 130,
});
});
it("keeps mention queries active across spaces", () => {
expect(findMentionMatch("Ping @Paperclip App", "Ping @Paperclip App".length)).toEqual({
trigger: "mention",
marker: "@",
query: "Paperclip App",
atPos: 5,
endPos: "Ping @Paperclip App".length,
});
});
it("still rejects slash commands once spaces are typed", () => {
expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull();
});
it("keeps routine slash queries active across spaces", () => {
expect(findMentionMatch("/routine:Weekly release review", "/routine:Weekly release review".length)).toEqual({
trigger: "skill",
marker: "/",
query: "routine:Weekly release review",
atPos: 0,
endPos: "/routine:Weekly release review".length,
});
});
it("does not treat Enter as skill autocomplete accept", () => {
expect(shouldAcceptAutocompleteKey("Enter", "skill")).toBe(false);
expect(shouldAcceptAutocompleteKey("Enter", "skill", true)).toBe(true);
expect(shouldAcceptAutocompleteKey("Enter", "mention")).toBe(true);
expect(shouldAcceptAutocompleteKey("Tab", "skill")).toBe(true);
});
it("keeps the same autocomplete session active while the slash query is unchanged", () => {
const textNode = document.createTextNode("/agent");
expect(isSameAutocompleteSession(
{
trigger: "skill",
marker: "/",
query: "agent",
textNode,
atPos: 0,
endPos: 6,
},
{
trigger: "skill",
marker: "/",
query: "agent",
textNode,
atPos: 0,
endPos: 6,
},
)).toBe(true);
expect(isSameAutocompleteSession(
{
trigger: "skill",
marker: "/",
query: "agent",
textNode,
atPos: 0,
endPos: 6,
},
{
trigger: "skill",
marker: "/",
query: "agent-browser",
textNode,
atPos: 0,
endPos: 14,
},
)).toBe(false);
});
it("finds skill anchors by mention metadata instead of visible text", () => {
const editable = document.createElement("div");
const skillLink = document.createElement("a");
skillLink.setAttribute("href", buildSkillMentionHref("skill-123", "agent-browser"));
skillLink.textContent = "/agent-browser ";
editable.appendChild(skillLink);
const found = findClosestAutocompleteAnchor(editable, {
id: "skill:skill-123",
kind: "skill",
skillId: "skill-123",
key: "agent-browser",
name: "Agent Browser",
slug: "agent-browser",
description: null,
href: buildSkillMentionHref("skill-123", "agent-browser"),
aliases: ["agent-browser", "Agent Browser"],
});
expect(found).toBe(skillLink);
});
it("finds routine anchors by mention metadata instead of visible text", () => {
const editable = document.createElement("div");
const routineLink = document.createElement("a");
routineLink.setAttribute("href", buildRoutineMentionHref("routine-123"));
routineLink.textContent = "/routine:Weekly release review ";
editable.appendChild(routineLink);
const found = findClosestAutocompleteAnchor(editable, {
id: "routine:routine-123",
kind: "routine",
routineId: "routine-123",
name: "Weekly release review",
status: "active",
href: buildRoutineMentionHref("routine-123"),
aliases: ["routine:Weekly release review", "Weekly release review"],
});
expect(found).toBe(routineLink);
});
it("places the caret after the mention's trailing space when present", () => {
const editable = document.createElement("div");
editable.contentEditable = "true";
document.body.appendChild(editable);
const skillLink = document.createElement("a");
skillLink.setAttribute("href", buildSkillMentionHref("skill-123", "agent-browser"));
skillLink.textContent = "/agent-browser";
const trailingSpace = document.createTextNode(" ");
editable.append(skillLink, trailingSpace);
expect(placeCaretAfterMentionAnchor(skillLink)).toBe(true);
const selection = window.getSelection();
expect(selection?.anchorNode).toBe(trailingSpace);
expect(selection?.anchorOffset).toBe(1);
editable.remove();
});
function createTouchEvent(
type: "touchstart" | "touchmove" | "touchend",
touches: Array<{ clientX: number; clientY: number }>,
) {
const event = new Event(type, { bubbles: true, cancelable: true });
const list = touches as unknown as TouchList;
Object.defineProperty(event, "touches", { value: type === "touchend" ? [] : list });
Object.defineProperty(event, "changedTouches", { value: list });
return event;
}
async function openMentionMenuFor(
handleChange: ReturnType<typeof vi.fn>,
): Promise<{ option: HTMLButtonElement; root: ReturnType<typeof createRoot> }> {
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value="@Pap"
onChange={handleChange}
mentions={[
{
id: "project:project-123",
kind: "project",
name: "Paperclip App",
projectId: "project-123",
projectColor: "#336699",
},
]}
/>,
);
});
await flush();
const editable = container.querySelector('[contenteditable="true"]');
expect(editable).not.toBeNull();
const textNode = editable?.firstChild;
expect(textNode?.nodeType).toBe(Node.TEXT_NODE);
const selection = window.getSelection();
const range = document.createRange();
range.setStart(textNode!, "@Pap".length);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
act(() => {
document.dispatchEvent(new Event("selectionchange"));
});
await flush();
const option = Array.from(document.body.querySelectorAll('button[type="button"]'))
.find((node) => node.textContent?.includes("Paperclip App")) as HTMLButtonElement | undefined;
expect(option).toBeTruthy();
return { option: option!, root };
}
it("accepts mention selection from a touch tap", async () => {
const handleChange = vi.fn();
const { option, root } = await openMentionMenuFor(handleChange);
const point = { clientX: 100, clientY: 50 };
act(() => {
option.dispatchEvent(createTouchEvent("touchstart", [point]));
});
act(() => {
option.dispatchEvent(createTouchEvent("touchend", [point]));
});
expect(handleChange).toHaveBeenCalledWith(
`[@Paperclip App](${buildProjectMentionHref("project-123", "#336699")}) `,
);
await act(async () => {
root.unmount();
});
});
it("does not preventDefault on touchstart so the mention menu can scroll on mobile", async () => {
const handleChange = vi.fn();
const { option, root } = await openMentionMenuFor(handleChange);
const touchstart = createTouchEvent("touchstart", [{ clientX: 100, clientY: 50 }]);
act(() => {
option.dispatchEvent(touchstart);
});
expect(touchstart.defaultPrevented).toBe(false);
expect(handleChange).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
it("does not select when the touch moves like a scroll", async () => {
const handleChange = vi.fn();
const { option, root } = await openMentionMenuFor(handleChange);
const start = { clientX: 100, clientY: 50 };
const moved = { clientX: 100, clientY: 90 };
act(() => {
option.dispatchEvent(createTouchEvent("touchstart", [start]));
});
act(() => {
option.dispatchEvent(createTouchEvent("touchmove", [moved]));
});
act(() => {
option.dispatchEvent(createTouchEvent("touchend", [moved]));
});
expect(handleChange).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
});