Files
paseo/packages/app/src/review/surface.test.tsx
T
Mohamed Boudra b26b9234c0 Improve diff review with mobile comment sheets and direct feedback (#6455)
* feat(review): edit compact diff comments in a sheet

* feat(review): send diff feedback to workspace agents

Send saved reviews from the diff while retaining file navigation and unrelated composer drafts. Clear acknowledged versions only so comments edited or added during submission remain available.

* fix(review): preserve drafts and coordinate feedback across surfaces

Share pending submission ownership by draft while keeping recipient menus local. Retain editable text across presentation changes and acknowledgement, and reserve safe-area clearance for floating actions.

* fix(review): center a small primary feedback button on desktop

* fix(review): restore focus to feedback button after closing menu
2026-10-09 21:31:06 +01:00

459 lines
14 KiB
TypeScript

// @vitest-environment jsdom
import "@/test/window-local-storage";
import { i18n as testI18n } from "@/i18n/i18next";
import { act, fireEvent, render, renderHook, cleanup } from "@testing-library/react";
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { useReviewDraftStore, type ReviewDraftComment } from "./store";
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
import {
getInlineReviewThreadState,
getSplitInlineReviewThreadState,
type InlineReviewActions,
} from "./geometry";
import {
getInlineReviewThreadViewportStyle,
groupInlineReviewCommentsByTarget,
InlineReviewEditor,
InlineReviewGutterCell,
InlineReviewThread,
SMALL_ACTION_HIT_SLOP,
useInlineReviewController,
} from "./surface";
void testI18n;
const { pressablePropsByLabel } = vi.hoisted(() => {
Object.assign(globalThis, { __DEV__: false });
return { pressablePropsByLabel: new Map<string, Record<string, unknown>>() };
});
vi.mock("react-native", async (importOriginal) => {
const ReactModule = await import("react");
const actual = await importOriginal<typeof import("react-native")>();
return {
...actual,
Pressable: ({
accessibilityLabel,
children,
onPress,
...props
}: {
accessibilityLabel?: string;
children?:
| React.ReactNode
| ((state: { hovered: boolean; pressed: boolean }) => React.ReactNode);
onPress?: () => void;
[key: string]: unknown;
}) => {
if (accessibilityLabel) {
pressablePropsByLabel.set(accessibilityLabel, props);
}
const resolvedChildren =
typeof children === "function" ? children({ hovered: false, pressed: false }) : children;
return ReactModule.createElement(
"button",
{
"aria-label": accessibilityLabel,
"data-testid": typeof props.testID === "string" ? props.testID : undefined,
disabled: props.disabled === true,
onClick: onPress,
type: "button",
},
resolvedChildren,
);
},
};
});
vi.mock("react-native-unistyles", async () => {
const { darkTheme: theme } = await import("@/styles/theme");
return {
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }),
};
});
vi.mock("@/constants/platform", () => ({
getIsElectron: () => false,
getIsElectronMac: () => false,
isNative: false,
isWeb: true,
}));
function target(overrides: Partial<ReviewableDiffTarget> = {}): ReviewableDiffTarget {
return {
filePath: "src/example.ts",
hunkHeader: "@@ -1,2 +1,2 @@",
hunkIndex: 0,
lineIndex: 2,
oldLineNumber: null,
newLineNumber: 2,
side: "new",
lineNumber: 2,
lineType: "add",
content: "const value = next;",
...overrides,
key: buildReviewableDiffTargetKey({
filePath: overrides.filePath ?? "src/example.ts",
side: overrides.side ?? "new",
lineNumber: overrides.lineNumber ?? 2,
}),
};
}
const EMPTY_COMMENTS: ReviewDraftComment[] = [];
const COMMENT_LIST: ReviewDraftComment[] = [comment()];
function buildReviewActions(overrides: Partial<InlineReviewActions> = {}): InlineReviewActions {
return {
commentsByTarget: new Map(),
editor: null,
onStartComment: vi.fn(),
onEditComment: vi.fn(),
onCancelEditor: vi.fn(),
onChangeEditorBody: () => {},
onSaveEditor: vi.fn(),
onDeleteComment: vi.fn(),
...overrides,
};
}
function comment(overrides: Partial<ReviewDraftComment> = {}): ReviewDraftComment {
return {
id: "comment-1",
filePath: "src/example.ts",
side: "new",
lineNumber: 2,
body: "Please simplify this.",
createdAt: "2026-04-21T00:00:00.000Z",
updatedAt: "2026-04-21T00:00:00.000Z",
...overrides,
};
}
describe("useInlineReviewController", () => {
beforeEach(() => {
useReviewDraftStore.setState({ drafts: {} });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("owns draft comment add, edit, delete, cancel, and key-change editor reset", () => {
const reviewTarget = target();
const firstKey = "review:key-1";
const secondKey = "review:key-2";
const { result, rerender } = renderHook(
({ reviewDraftKey }) => useInlineReviewController({ reviewDraftKey }),
{ initialProps: { reviewDraftKey: firstKey } },
);
act(() => result.current.onStartComment(reviewTarget));
expect(result.current.editor).toEqual({ target: reviewTarget, commentId: null, body: "" });
act(() => result.current.onSaveEditor(" first comment "));
const savedComment = useReviewDraftStore.getState().drafts[firstKey]?.[0];
expect(savedComment).toMatchObject({
filePath: "src/example.ts",
side: "new",
lineNumber: 2,
body: "first comment",
});
expect(result.current.editor).toBeNull();
expect(result.current.commentsByTarget.get(reviewTarget.key)?.[0]).toMatchObject({
body: "first comment",
});
act(() => result.current.onEditComment(reviewTarget, savedComment));
expect(result.current.editor).toEqual({
target: reviewTarget,
commentId: savedComment?.id,
body: "first comment",
});
act(() => result.current.onSaveEditor(" updated comment "));
const updatedComment = useReviewDraftStore.getState().drafts[firstKey]?.[0];
expect(updatedComment).toMatchObject({ id: savedComment?.id, body: "updated comment" });
act(() => result.current.onEditComment(reviewTarget, updatedComment));
act(() => result.current.onDeleteComment(updatedComment.id));
expect(useReviewDraftStore.getState().drafts[firstKey]).toEqual([]);
expect(result.current.editor).toBeNull();
act(() => result.current.onStartComment(reviewTarget));
act(() => result.current.onCancelEditor());
expect(result.current.editor).toBeNull();
act(() => result.current.onStartComment(reviewTarget));
rerender({ reviewDraftKey: secondKey });
expect(result.current.editor).toBeNull();
});
});
describe("git diff inline review helpers", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
pressablePropsByLabel.clear();
});
it("maps persisted draft comments to their reviewable diff target", () => {
const comments = [
comment(),
comment({ id: "comment-2", side: "old", lineNumber: 8 }),
comment({ id: "comment-3", filePath: "src/other.ts" }),
];
const commentsByTarget = groupInlineReviewCommentsByTarget(comments);
expect(commentsByTarget.get("src/example.ts:new:2")).toEqual([comments[0]]);
expect(commentsByTarget.get("src/example.ts:old:8")).toEqual([comments[1]]);
expect(commentsByTarget.get("src/other.ts:new:2")).toEqual([comments[2]]);
expect(
getInlineReviewThreadState({
reviewTarget: target(),
reviewActions: buildReviewActions({ commentsByTarget }),
})?.comments,
).toEqual([comments[0]]);
});
it("reserves split inline review height from the taller side", () => {
const leftTarget = target({ side: "old", lineNumber: 8, oldLineNumber: 8 });
const rightTarget = target();
const rightComment = comment();
const actions = buildReviewActions({
commentsByTarget: groupInlineReviewCommentsByTarget([rightComment]),
editor: { target: rightTarget, commentId: null, body: "" },
});
const rowState = getSplitInlineReviewThreadState({
left: leftTarget,
right: rightTarget,
reviewActions: actions,
});
expect(rowState?.left).toBeNull();
expect(rowState?.right?.comments).toEqual([rightComment]);
expect(rowState?.height).toBe(226);
});
it("includes thread padding in the inline editor height", () => {
const reviewTarget = target();
const actions = buildReviewActions({
editor: { target: reviewTarget, commentId: null, body: "" },
});
expect(getInlineReviewThreadState({ reviewTarget, reviewActions: actions })?.height).toBe(148);
});
it("pins no-wrap review threads to the visible diff viewport", () => {
expect(
getInlineReviewThreadViewportStyle({
viewportWidth: 320,
pinToViewport: true,
}),
).toEqual([{ position: "sticky", left: 0 }, inlineUnistylesStyle({ width: 320 })]);
});
it("keeps the gutter add-comment target accessible and clicking opens the editor", () => {
const onStartComment = vi.fn();
const reviewTarget = target();
const { getByLabelText } = render(
<InlineReviewGutterCell
reviewTarget={reviewTarget}
comments={EMPTY_COMMENTS}
isEditorOpen={false}
onStartComment={onStartComment}
>
<span>2</span>
</InlineReviewGutterCell>,
);
fireEvent.click(getByLabelText("Add review comment"));
expect(onStartComment).toHaveBeenCalledWith(reviewTarget);
expect(pressablePropsByLabel.get("Add review comment")?.hitSlop).toBe(SMALL_ACTION_HIT_SLOP);
});
it("keeps the line number visible and only floats the plus for line hover", () => {
const reviewTarget = target();
const { queryByTestId, queryByText, rerender } = render(
<InlineReviewGutterCell
actionTestID="add-comment-glyph"
reviewTarget={reviewTarget}
comments={EMPTY_COMMENTS}
isEditorOpen={false}
onStartComment={vi.fn()}
>
<span>2</span>
</InlineReviewGutterCell>,
);
expect(queryByText("2")).toBeTruthy();
expect(queryByTestId("add-comment-glyph")).toBeNull();
rerender(
<InlineReviewGutterCell
actionTestID="add-comment-glyph"
reviewTarget={reviewTarget}
comments={COMMENT_LIST}
isEditorOpen={false}
onStartComment={vi.fn()}
>
<span>2</span>
</InlineReviewGutterCell>,
);
expect(queryByText("2")).toBeTruthy();
expect(queryByTestId("add-comment-glyph")).toBeNull();
rerender(
<InlineReviewGutterCell
actionTestID="add-comment-glyph"
reviewTarget={reviewTarget}
comments={EMPTY_COMMENTS}
isEditorOpen
onStartComment={vi.fn()}
>
<span>2</span>
</InlineReviewGutterCell>,
);
expect(queryByText("2")).toBeTruthy();
expect(queryByTestId("add-comment-glyph")).toBeNull();
rerender(
<InlineReviewGutterCell
actionTestID="add-comment-glyph"
reviewTarget={reviewTarget}
comments={COMMENT_LIST}
isEditorOpen={false}
isLineHovered
onStartComment={vi.fn()}
>
<span>2</span>
</InlineReviewGutterCell>,
);
expect(queryByText("2")).toBeTruthy();
expect(queryByTestId("add-comment-glyph")).toBeTruthy();
});
});
describe("InlineReviewEditor", () => {
const originalMatchMedia = window.matchMedia;
afterEach(() => {
window.matchMedia = originalMatchMedia;
cleanup();
vi.clearAllMocks();
});
it("saves trimmed bodies and cancels without saving", () => {
const onCancel = vi.fn();
const onSave = vi.fn();
const { getByTestId } = render(
<ReviewEditorFixture
initialBody=" initial "
onCancel={onCancel}
onSave={onSave}
testID="editor"
/>,
);
fireEvent.change(getByTestId("editor-input"), { target: { value: " updated comment " } });
fireEvent.click(getByTestId("editor-save"));
expect(onSave).toHaveBeenCalledWith("updated comment");
fireEvent.click(getByTestId("editor-cancel"));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("handles Escape cancel and Mod+Enter save from the focused textarea", () => {
const onCancel = vi.fn();
const onSave = vi.fn();
const { getByTestId } = render(
<ReviewEditorFixture
initialBody="ready"
onCancel={onCancel}
onSave={onSave}
testID="editor"
/>,
);
const input = getByTestId("editor-input");
fireEvent.focus(input);
fireEvent.keyDown(input, { key: "Escape" });
expect(onCancel).toHaveBeenCalledTimes(1);
fireEvent.keyDown(input, { key: "Enter", metaKey: true });
expect(onSave).toHaveBeenCalledWith("ready");
});
it("does not show shortcut hints in the action buttons", () => {
window.matchMedia = vi.fn().mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const { getByTestId, queryByText } = render(
<ReviewEditorFixture
initialBody="ready"
onCancel={vi.fn()}
onSave={vi.fn()}
testID="editor"
/>,
);
const input = getByTestId("editor-input");
fireEvent.focus(input);
expect(queryByText("Esc")).toBeNull();
expect(queryByText(/(?:⌘⏎|Ctrl\+⏎)/)).toBeNull();
});
});
describe("InlineReviewThread", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("exposes edit and delete actions for existing comments", () => {
const reviewTarget = target();
const draftComment = comment();
const actions = buildReviewActions({
commentsByTarget: groupInlineReviewCommentsByTarget([draftComment]),
});
const { getByTestId, getByText } = render(
<InlineReviewThread
reviewTarget={reviewTarget}
reviewActions={actions}
height={76}
testID="thread"
/>,
);
expect(getByText("Please simplify this.")).toBeTruthy();
fireEvent.click(getByTestId("review-comment-edit-comment-1"));
expect(actions.onEditComment).toHaveBeenCalledWith(reviewTarget, draftComment);
fireEvent.click(getByTestId("review-comment-delete-comment-1"));
expect(actions.onDeleteComment).toHaveBeenCalledWith("comment-1");
});
});
function ReviewEditorFixture({
initialBody,
...props
}: Omit<React.ComponentProps<typeof InlineReviewEditor>, "body" | "onChangeBody"> & {
initialBody: string;
}) {
const [body, onChangeBody] = React.useState(initialBody);
return <InlineReviewEditor {...props} body={body} onChangeBody={onChangeBody} />;
}