mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 20:32:46 +00:00
refactor(app): replace screen test slop batch 2 with proper coverage (#725)
Deletes four screen-level .test.tsx files (2600+ lines of vi.mock + JSDOM slop) and replaces each with verified coverage: - sessions-screen.test.tsx: covered by archive-tab.spec.ts which exercises the sessions screen via openSessions() with real agents - workspace-draft-agent-tab.test.tsx: covered by new-workspace.spec.ts "redirects to optimistic draft tab before agent creation resolves" - new-workspace-screen.test.tsx: covered by new-workspace.spec.ts (main submit, branch selection, PR selection, optimistic draft tab) plus new-workspace-picker-item.test.ts (pickerItemToCheckoutRequest) - project-settings-screen.test.tsx: covered by project-config-form.test.ts (all round-trip string/array lifecycle semantics) and projects-settings.spec.ts (save flow with passthrough field preservation) Extracts syncPickerPrAttachment from new-workspace-screen.tsx into a new pure module new-workspace-picker-state.ts with 5 zero-mock unit tests covering: initial PR selection, branch selection without change, PR replacement, PR removal on branch switch, and no-duplicate guard.
This commit is contained in:
96
packages/app/src/screens/new-workspace-picker-state.test.ts
Normal file
96
packages/app/src/screens/new-workspace-picker-state.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
|
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||||
|
import { syncPickerPrAttachment } from "./new-workspace-picker-state";
|
||||||
|
|
||||||
|
function makePrItem(number: number, title: string, headRefName = "feature/x"): GitHubSearchItem {
|
||||||
|
return {
|
||||||
|
kind: "pr",
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
url: `https://example.com/pull/${number}`,
|
||||||
|
state: "open",
|
||||||
|
body: null,
|
||||||
|
labels: [],
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function prAttachment(item: GitHubSearchItem): UserComposerAttachment {
|
||||||
|
return { kind: "github_pr", item };
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueAttachment(number: number): UserComposerAttachment {
|
||||||
|
return {
|
||||||
|
kind: "github_issue",
|
||||||
|
item: {
|
||||||
|
kind: "issue",
|
||||||
|
number,
|
||||||
|
title: `Issue ${number}`,
|
||||||
|
url: `https://example.com/issues/${number}`,
|
||||||
|
state: "open",
|
||||||
|
body: null,
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("syncPickerPrAttachment", () => {
|
||||||
|
it("selects a PR when no previous picker PR is set", () => {
|
||||||
|
const pr = makePrItem(202, "Refactor picker");
|
||||||
|
const result = syncPickerPrAttachment({
|
||||||
|
attachments: [],
|
||||||
|
previousPickerPrNumber: null,
|
||||||
|
item: { kind: "github-pr", item: pr },
|
||||||
|
});
|
||||||
|
expect(result.attachedPrNumber).toBe(202);
|
||||||
|
expect(result.attachments).toEqual([prAttachment(pr)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a branch without modifying attachments when no previous picker PR", () => {
|
||||||
|
const issue = issueAttachment(44);
|
||||||
|
const result = syncPickerPrAttachment({
|
||||||
|
attachments: [issue],
|
||||||
|
previousPickerPrNumber: null,
|
||||||
|
item: { kind: "branch", name: "dev" },
|
||||||
|
});
|
||||||
|
expect(result.attachedPrNumber).toBeNull();
|
||||||
|
expect(result.attachments).toEqual([issue]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the previous picker PR when a different PR is selected", () => {
|
||||||
|
const prA = makePrItem(202, "Refactor picker", "feature/picker");
|
||||||
|
const prB = makePrItem(303, "Polish chip", "feature/chip");
|
||||||
|
const result = syncPickerPrAttachment({
|
||||||
|
attachments: [prAttachment(prA)],
|
||||||
|
previousPickerPrNumber: 202,
|
||||||
|
item: { kind: "github-pr", item: prB },
|
||||||
|
});
|
||||||
|
expect(result.attachedPrNumber).toBe(303);
|
||||||
|
expect(result.attachments).toEqual([prAttachment(prB)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the previous picker PR and adds no new attachment when a branch is selected", () => {
|
||||||
|
const pr = makePrItem(202, "Refactor picker");
|
||||||
|
const issue = issueAttachment(44);
|
||||||
|
const result = syncPickerPrAttachment({
|
||||||
|
attachments: [issue, prAttachment(pr)],
|
||||||
|
previousPickerPrNumber: 202,
|
||||||
|
item: { kind: "branch", name: "dev" },
|
||||||
|
});
|
||||||
|
expect(result.attachedPrNumber).toBeNull();
|
||||||
|
expect(result.attachments).toEqual([issue]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate a PR that was already manually attached by the user", () => {
|
||||||
|
const pr = makePrItem(202, "Refactor picker");
|
||||||
|
const result = syncPickerPrAttachment({
|
||||||
|
attachments: [prAttachment(pr)],
|
||||||
|
previousPickerPrNumber: null,
|
||||||
|
item: { kind: "github-pr", item: pr },
|
||||||
|
});
|
||||||
|
expect(result.attachedPrNumber).toBeNull();
|
||||||
|
expect(result.attachments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
35
packages/app/src/screens/new-workspace-picker-state.ts
Normal file
35
packages/app/src/screens/new-workspace-picker-state.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
|
import type { PickerItem } from "./new-workspace-picker-item";
|
||||||
|
|
||||||
|
// The picker "owns" at most one PR attachment at a time. When the user selects
|
||||||
|
// a different item the previously-owned PR is removed before the new one is added.
|
||||||
|
// User-added attachments for other PRs/issues are left untouched.
|
||||||
|
export function syncPickerPrAttachment(input: {
|
||||||
|
attachments: UserComposerAttachment[];
|
||||||
|
previousPickerPrNumber: number | null;
|
||||||
|
item: PickerItem;
|
||||||
|
}): { attachments: UserComposerAttachment[]; attachedPrNumber: number | null } {
|
||||||
|
let nextAttachments = input.attachments;
|
||||||
|
let attachedPrNumber: number | null = null;
|
||||||
|
|
||||||
|
if (input.previousPickerPrNumber !== null) {
|
||||||
|
nextAttachments = nextAttachments.filter(
|
||||||
|
(attachment) =>
|
||||||
|
attachment.kind !== "github_pr" || attachment.item.number !== input.previousPickerPrNumber,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.item.kind === "github-pr") {
|
||||||
|
const selectedPr = input.item.item;
|
||||||
|
const hasExistingPrAttachment = nextAttachments.some(
|
||||||
|
(attachment) =>
|
||||||
|
attachment.kind === "github_pr" && attachment.item.number === selectedPr.number,
|
||||||
|
);
|
||||||
|
if (!hasExistingPrAttachment) {
|
||||||
|
nextAttachments = [...nextAttachments, { kind: "github_pr", item: selectedPr }];
|
||||||
|
attachedPrNumber = selectedPr.number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { attachments: nextAttachments, attachedPrNumber };
|
||||||
|
}
|
||||||
@@ -1,982 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { ComposerAttachment } from "@/attachments/types";
|
|
||||||
import type { CreatePaseoWorktreeInput } from "@server/client/daemon-client";
|
|
||||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
|
||||||
import { NewWorkspaceScreen } from "./new-workspace-screen";
|
|
||||||
|
|
||||||
const {
|
|
||||||
theme,
|
|
||||||
mockClient,
|
|
||||||
mergeWorkspacesMock,
|
|
||||||
navigateMock,
|
|
||||||
saveDraftInputMock,
|
|
||||||
clearDraftInputMock,
|
|
||||||
queueDraftSubmissionMock,
|
|
||||||
createdAgent: _createdAgent,
|
|
||||||
createdWorkspace,
|
|
||||||
prItem,
|
|
||||||
prItemB,
|
|
||||||
issueItem,
|
|
||||||
initialAttachments,
|
|
||||||
initialDraftState,
|
|
||||||
} = vi.hoisted(() => {
|
|
||||||
const hoistedTheme = {
|
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
|
||||||
iconSize: { sm: 14, md: 18, lg: 22 },
|
|
||||||
borderWidth: { 1: 1 },
|
|
||||||
borderRadius: { md: 6, lg: 8, "2xl": 16 },
|
|
||||||
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
|
|
||||||
fontWeight: { medium: "500" },
|
|
||||||
shadow: { md: {} },
|
|
||||||
colors: {
|
|
||||||
surface0: "#000",
|
|
||||||
surface1: "#111",
|
|
||||||
surface2: "#222",
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
popoverForeground: "#fff",
|
|
||||||
border: "#555",
|
|
||||||
destructive: "#ff453a",
|
|
||||||
palette: {
|
|
||||||
zinc: { 600: "#52525b" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const hoistedPrItem: GitHubSearchItem = {
|
|
||||||
kind: "pr",
|
|
||||||
number: 202,
|
|
||||||
title: "Refactor picker",
|
|
||||||
url: "https://example.com/pull/202",
|
|
||||||
state: "open",
|
|
||||||
body: null,
|
|
||||||
labels: [],
|
|
||||||
baseRefName: "main",
|
|
||||||
headRefName: "feature/picker",
|
|
||||||
};
|
|
||||||
|
|
||||||
const hoistedPrItemB: GitHubSearchItem = {
|
|
||||||
kind: "pr",
|
|
||||||
number: 303,
|
|
||||||
title: "Polish composer chip",
|
|
||||||
url: "https://example.com/pull/303",
|
|
||||||
state: "open",
|
|
||||||
body: null,
|
|
||||||
labels: [],
|
|
||||||
baseRefName: "main",
|
|
||||||
headRefName: "feature/composer-chip",
|
|
||||||
};
|
|
||||||
|
|
||||||
const hoistedIssueItem: GitHubSearchItem = {
|
|
||||||
kind: "issue",
|
|
||||||
number: 44,
|
|
||||||
title: "Keep manual attachment",
|
|
||||||
url: "https://example.com/issues/44",
|
|
||||||
state: "open",
|
|
||||||
body: null,
|
|
||||||
labels: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const hoistedInitialAttachments: ComposerAttachment[] = [];
|
|
||||||
const hoistedInitialDraftState = { text: "" };
|
|
||||||
|
|
||||||
const hoistedCreatedWorkspace = {
|
|
||||||
id: "workspace-1",
|
|
||||||
workspaceDirectory: "/repo/.paseo/worktrees/workspace-1",
|
|
||||||
};
|
|
||||||
|
|
||||||
const hoistedCreatedAgent = {
|
|
||||||
id: "agent-1",
|
|
||||||
cwd: hoistedCreatedWorkspace.workspaceDirectory,
|
|
||||||
};
|
|
||||||
|
|
||||||
const hoistedMockClient = {
|
|
||||||
isConnected: true,
|
|
||||||
getCheckoutStatus: vi.fn(async () => ({ currentBranch: "main" })),
|
|
||||||
getBranchSuggestions: vi.fn(async () => ({ branches: ["main", "dev", "feat/x"] })),
|
|
||||||
searchGitHub: vi.fn(async () => ({
|
|
||||||
items: [hoistedPrItem, hoistedPrItemB],
|
|
||||||
githubFeaturesEnabled: true,
|
|
||||||
error: null,
|
|
||||||
})),
|
|
||||||
createPaseoWorktree: vi.fn(async (_input: CreatePaseoWorktreeInput) => ({
|
|
||||||
workspace: hoistedCreatedWorkspace,
|
|
||||||
error: null,
|
|
||||||
})),
|
|
||||||
createAgent: vi.fn(async () => hoistedCreatedAgent),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
theme: hoistedTheme,
|
|
||||||
mockClient: hoistedMockClient,
|
|
||||||
mergeWorkspacesMock: vi.fn(),
|
|
||||||
navigateMock: vi.fn(),
|
|
||||||
saveDraftInputMock: vi.fn(),
|
|
||||||
clearDraftInputMock: vi.fn(),
|
|
||||||
queueDraftSubmissionMock: vi.fn(),
|
|
||||||
createdAgent: hoistedCreatedAgent,
|
|
||||||
createdWorkspace: hoistedCreatedWorkspace,
|
|
||||||
prItem: hoistedPrItem,
|
|
||||||
prItemB: hoistedPrItemB,
|
|
||||||
issueItem: hoistedIssueItem,
|
|
||||||
initialAttachments: hoistedInitialAttachments,
|
|
||||||
initialDraftState: hoistedInitialDraftState,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ theme }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/constants/platform", () => ({
|
|
||||||
isWeb: true,
|
|
||||||
isNative: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/constants/layout", () => ({
|
|
||||||
HEADER_INNER_HEIGHT: 48,
|
|
||||||
HEADER_INNER_HEIGHT_MOBILE: 56,
|
|
||||||
HEADER_TOP_PADDING_MOBILE: 8,
|
|
||||||
MAX_CONTENT_WIDTH: 900,
|
|
||||||
useIsCompactFormFactor: () => false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-safe-area-context", () => ({
|
|
||||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
|
||||||
React.createElement("span", { ...props, "data-icon": name });
|
|
||||||
return {
|
|
||||||
ChevronDown: createIcon("ChevronDown"),
|
|
||||||
GitBranch: createIcon("GitBranch"),
|
|
||||||
GitPullRequest: createIcon("GitPullRequest"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function flattenReanimatedStyle(style: unknown) {
|
|
||||||
if (!Array.isArray(style)) {
|
|
||||||
return style;
|
|
||||||
}
|
|
||||||
return Object.assign({}, ...style.filter(Boolean));
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("react-native-reanimated", () => ({
|
|
||||||
default: {
|
|
||||||
View: ({
|
|
||||||
testID,
|
|
||||||
style,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement> & { testID?: string; style?: unknown }) => {
|
|
||||||
const flattenedStyle = flattenReanimatedStyle(style);
|
|
||||||
return <div {...props} data-testid={testID} style={flattenedStyle as React.CSSProperties} />;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
|
||||||
useHostRuntimeClient: () => mockClient,
|
|
||||||
useHostRuntimeIsConnected: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/session-store", () => ({
|
|
||||||
useSessionStore: (selector: (state: unknown) => unknown) =>
|
|
||||||
selector({
|
|
||||||
mergeWorkspaces: mergeWorkspacesMock,
|
|
||||||
}),
|
|
||||||
normalizeWorkspaceDescriptor: (workspace: unknown) => workspace,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/agent-snapshots", () => ({
|
|
||||||
normalizeAgentSnapshot: (agent: unknown) => agent,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/encode-images", () => ({
|
|
||||||
encodeImages: vi.fn(async () => []),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/error-messages", () => ({
|
|
||||||
toErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/workspace-execution", () => ({
|
|
||||||
requireWorkspaceExecutionAuthority: (input: { workspace: { workspaceDirectory: string } }) => ({
|
|
||||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/workspace-navigation", () => ({
|
|
||||||
navigateToPreparedWorkspaceTab: navigateMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/draft-keys", () => ({
|
|
||||||
buildDraftStoreKey: ({
|
|
||||||
serverId,
|
|
||||||
agentId,
|
|
||||||
draftId,
|
|
||||||
}: {
|
|
||||||
serverId: string;
|
|
||||||
agentId: string;
|
|
||||||
draftId?: string | null;
|
|
||||||
}) => (draftId ? `draft:${serverId}:${draftId}` : `agent:${serverId}:${agentId}`),
|
|
||||||
generateDraftId: () => "draft-new-workspace",
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/draft-store", () => ({
|
|
||||||
useDraftStore: {
|
|
||||||
getState: () => ({
|
|
||||||
saveDraftInput: saveDraftInputMock,
|
|
||||||
clearDraftInput: clearDraftInputMock,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/workspace-draft-submission-store", () => ({
|
|
||||||
useWorkspaceDraftSubmissionStore: {
|
|
||||||
getState: () => ({
|
|
||||||
setPending: queueDraftSubmissionMock,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/contexts/toast-context", () => ({
|
|
||||||
useToast: () => ({ error: vi.fn() }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-agent-input-draft", () => ({
|
|
||||||
useAgentInputDraft: () => {
|
|
||||||
const [attachments, setAttachments] = React.useState<ComposerAttachment[]>(initialAttachments);
|
|
||||||
const [text, setText] = React.useState(initialDraftState.text);
|
|
||||||
return {
|
|
||||||
text,
|
|
||||||
setText,
|
|
||||||
attachments,
|
|
||||||
setAttachments,
|
|
||||||
cwd: "/repo",
|
|
||||||
composerState: {
|
|
||||||
selectedProvider: "claude-code",
|
|
||||||
selectedMode: "",
|
|
||||||
modeOptions: [],
|
|
||||||
effectiveModelId: null,
|
|
||||||
effectiveThinkingOptionId: null,
|
|
||||||
commandDraftConfig: undefined,
|
|
||||||
statusControls: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
|
|
||||||
useKeyboardShiftStyle: () => ({ style: { transform: "translateY(-216px)" } }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface ComposerMockProps {
|
|
||||||
onSubmitMessage: (payload: {
|
|
||||||
text: string;
|
|
||||||
attachments: ComposerAttachment[];
|
|
||||||
cwd: string;
|
|
||||||
}) => void;
|
|
||||||
submitBehavior?: "clear" | "preserve-and-lock";
|
|
||||||
submitIcon?: "arrow" | "return";
|
|
||||||
isSubmitLoading?: boolean;
|
|
||||||
value: string;
|
|
||||||
onChangeText: (text: string) => void;
|
|
||||||
attachments: ComposerAttachment[];
|
|
||||||
onChangeAttachments: (attachments: ComposerAttachment[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AttachmentPillProps {
|
|
||||||
attachment: Extract<ComposerAttachment, { kind: "github_pr" | "github_issue" }>;
|
|
||||||
attachments: ComposerAttachment[];
|
|
||||||
isDisabled: boolean;
|
|
||||||
onChangeAttachments: (attachments: ComposerAttachment[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComposerMockAttachmentPill({
|
|
||||||
attachment,
|
|
||||||
attachments,
|
|
||||||
isDisabled,
|
|
||||||
onChangeAttachments,
|
|
||||||
}: AttachmentPillProps) {
|
|
||||||
const handleRemove = React.useCallback(() => {
|
|
||||||
onChangeAttachments(
|
|
||||||
attachments.filter(
|
|
||||||
(candidate) =>
|
|
||||||
candidate.kind !== attachment.kind || candidate.item.number !== attachment.item.number,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, [attachment, attachments, onChangeAttachments]);
|
|
||||||
return (
|
|
||||||
<div data-testid="composer-github-attachment-pill">
|
|
||||||
#{attachment.item.number} {attachment.item.title}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`Remove ${attachment.kind === "github_pr" ? "PR" : "issue"} #${attachment.item.number}`}
|
|
||||||
disabled={isDisabled}
|
|
||||||
onClick={handleRemove}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComposerMock({
|
|
||||||
onSubmitMessage,
|
|
||||||
submitBehavior,
|
|
||||||
submitIcon,
|
|
||||||
isSubmitLoading,
|
|
||||||
value,
|
|
||||||
onChangeText,
|
|
||||||
attachments,
|
|
||||||
onChangeAttachments,
|
|
||||||
}: ComposerMockProps) {
|
|
||||||
const isDisabled = submitBehavior === "preserve-and-lock" && Boolean(isSubmitLoading);
|
|
||||||
const handleTextareaChange = React.useCallback(
|
|
||||||
(event: React.ChangeEvent<HTMLTextAreaElement>) => onChangeText(event.currentTarget.value),
|
|
||||||
[onChangeText],
|
|
||||||
);
|
|
||||||
const handleSubmit = React.useCallback(
|
|
||||||
() => onSubmitMessage({ text: value, attachments, cwd: "/repo" }),
|
|
||||||
[onSubmitMessage, value, attachments],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-testid="test-composer"
|
|
||||||
data-submit-behavior={submitBehavior}
|
|
||||||
data-submit-icon={submitIcon}
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
aria-label="Message agent..."
|
|
||||||
disabled={isDisabled}
|
|
||||||
value={value}
|
|
||||||
onChange={handleTextareaChange}
|
|
||||||
/>
|
|
||||||
<button type="button" data-testid="message-input-attach-button" disabled={isDisabled}>
|
|
||||||
Attach
|
|
||||||
</button>
|
|
||||||
{attachments.map((attachment) =>
|
|
||||||
attachment.kind === "github_pr" || attachment.kind === "github_issue" ? (
|
|
||||||
<ComposerMockAttachmentPill
|
|
||||||
key={`${attachment.kind}-${attachment.item.number}`}
|
|
||||||
attachment={attachment}
|
|
||||||
attachments={attachments}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
onChangeAttachments={onChangeAttachments}
|
|
||||||
/>
|
|
||||||
) : null,
|
|
||||||
)}
|
|
||||||
<button type="button" data-testid="test-composer-submit" onClick={handleSubmit}>
|
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("@/components/composer", () => ({
|
|
||||||
Composer: ComposerMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/composer-attachments", () => ({
|
|
||||||
splitComposerAttachmentsForSubmit: (attachments: ComposerAttachment[]) => ({
|
|
||||||
images: [],
|
|
||||||
attachments: attachments.flatMap((attachment) => {
|
|
||||||
if (attachment.kind === "github_pr") {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: "github_pr",
|
|
||||||
mimeType: "application/github-pr",
|
|
||||||
number: attachment.item.number,
|
|
||||||
title: attachment.item.title,
|
|
||||||
url: attachment.item.url,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (attachment.kind === "github_issue") {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: "github_issue",
|
|
||||||
mimeType: "application/github-issue",
|
|
||||||
number: attachment.item.number,
|
|
||||||
title: attachment.item.title,
|
|
||||||
url: attachment.item.url,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/desktop/titlebar-drag-region", () => ({
|
|
||||||
TitlebarDragRegion: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/headers/menu-header", () => ({
|
|
||||||
SidebarMenuToggle: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/headers/screen-header", () => ({
|
|
||||||
ScreenHeader: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/tooltip", () => ({
|
|
||||||
Tooltip: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
TooltipContent: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function ComboboxOptionButton({
|
|
||||||
option,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
option: { id: string; label: string };
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const handleClick = React.useCallback(() => onSelect(option.id), [onSelect, option.id]);
|
|
||||||
return (
|
|
||||||
<button type="button" onClick={handleClick}>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComboboxOptionRenderWrapper({
|
|
||||||
option,
|
|
||||||
onSelect,
|
|
||||||
renderOption,
|
|
||||||
}: {
|
|
||||||
option: { id: string; label: string };
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
renderOption: (input: {
|
|
||||||
option: { id: string; label: string };
|
|
||||||
selected: boolean;
|
|
||||||
active: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
}) => React.ReactElement;
|
|
||||||
}) {
|
|
||||||
const handlePress = React.useCallback(() => onSelect(option.id), [onSelect, option.id]);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{renderOption({
|
|
||||||
option,
|
|
||||||
selected: false,
|
|
||||||
active: false,
|
|
||||||
onPress: handlePress,
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/combobox", () => ({
|
|
||||||
Combobox: ({
|
|
||||||
open,
|
|
||||||
options,
|
|
||||||
renderOption,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
open?: boolean;
|
|
||||||
options: Array<{ id: string; label: string }>;
|
|
||||||
renderOption?: (input: {
|
|
||||||
option: { id: string; label: string };
|
|
||||||
selected: boolean;
|
|
||||||
active: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
}) => React.ReactElement;
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
}) => {
|
|
||||||
if (!open) return null;
|
|
||||||
return (
|
|
||||||
<div data-testid="ref-picker-combobox">
|
|
||||||
{options.map((option) =>
|
|
||||||
renderOption ? (
|
|
||||||
<ComboboxOptionRenderWrapper
|
|
||||||
key={option.id}
|
|
||||||
option={option}
|
|
||||||
onSelect={onSelect}
|
|
||||||
renderOption={renderOption}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ComboboxOptionButton key={option.id} option={option} onSelect={onSelect} />
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
ComboboxItem: ({
|
|
||||||
label,
|
|
||||||
onPress,
|
|
||||||
testID,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
onPress: () => void;
|
|
||||||
testID?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) => (
|
|
||||||
<button type="button" data-testid={testID} disabled={disabled} onClick={onPress}>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("mnemonic-id", () => ({
|
|
||||||
createNameId: () => "gentle-slug",
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native", async () => {
|
|
||||||
const actual = await vi.importActual<Record<string, unknown>>("react-native");
|
|
||||||
return actual;
|
|
||||||
});
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
let queryClient: QueryClient | 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);
|
|
||||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
mockClient.getBranchSuggestions.mockClear();
|
|
||||||
mockClient.searchGitHub.mockClear();
|
|
||||||
mockClient.searchGitHub.mockResolvedValue({
|
|
||||||
items: [prItem, prItemB],
|
|
||||||
githubFeaturesEnabled: true,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
mockClient.createPaseoWorktree.mockClear();
|
|
||||||
mockClient.createAgent.mockClear();
|
|
||||||
saveDraftInputMock.mockClear();
|
|
||||||
clearDraftInputMock.mockClear();
|
|
||||||
queueDraftSubmissionMock.mockClear();
|
|
||||||
navigateMock.mockClear();
|
|
||||||
initialAttachments.length = 0;
|
|
||||||
initialDraftState.text = "";
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
queryClient?.clear();
|
|
||||||
root = null;
|
|
||||||
container = null;
|
|
||||||
queryClient = null;
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderScreen() {
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<QueryClientProvider client={queryClient!}>
|
|
||||||
<NewWorkspaceScreen serverId="server" sourceDirectory="/repo" />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function click(element: Element) {
|
|
||||||
act(() => {
|
|
||||||
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function flush() {
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findByTestId(testID: string): Promise<HTMLElement> {
|
|
||||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
||||||
await flush();
|
|
||||||
const element = document.querySelector(`[data-testid="${testID}"]`) as HTMLElement | null;
|
|
||||||
if (element) return element;
|
|
||||||
}
|
|
||||||
throw new Error(`Missing element with testID ${testID}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function queryByTestId(testID: string): HTMLElement | null {
|
|
||||||
return document.querySelector(`[data-testid="${testID}"]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function queryAllByTestId(testID: string): HTMLElement[] {
|
|
||||||
return Array.from(document.querySelectorAll(`[data-testid="${testID}"]`)) as HTMLElement[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreatePaseoWorktreeArg = Parameters<typeof mockClient.createPaseoWorktree>[0];
|
|
||||||
|
|
||||||
function createDeferredPromise<T>() {
|
|
||||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
||||||
let reject!: (reason?: unknown) => void;
|
|
||||||
const promise = new Promise<T>((nextResolve, nextReject) => {
|
|
||||||
resolve = nextResolve;
|
|
||||||
reject = nextReject;
|
|
||||||
});
|
|
||||||
return { promise, resolve, reject };
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstCreateWorktreeCall(): CreatePaseoWorktreeArg {
|
|
||||||
const calls = mockClient.createPaseoWorktree.mock.calls;
|
|
||||||
const firstCall = calls[0];
|
|
||||||
if (!firstCall) throw new Error("createPaseoWorktree not called");
|
|
||||||
return firstCall[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("NewWorkspaceScreen picker payload", () => {
|
|
||||||
it("moves the ref picker row with the mobile keyboard shift", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const pickerRow = await findByTestId("new-workspace-ref-picker-row");
|
|
||||||
expect(pickerRow.style.transform).toBe("translateY(-216px)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lazily searches only GitHub PRs when the picker opens", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(mockClient.searchGitHub).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(mockClient.searchGitHub).toHaveBeenCalledWith({
|
|
||||||
cwd: "/repo",
|
|
||||||
query: "",
|
|
||||||
limit: 20,
|
|
||||||
kinds: ["github-pr"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends no new fields when nothing is selected (default)", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("test-composer-submit"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
|
|
||||||
const call = firstCreateWorktreeCall();
|
|
||||||
expect(call).toMatchObject({
|
|
||||||
cwd: "/repo",
|
|
||||||
worktreeSlug: "gentle-slug",
|
|
||||||
});
|
|
||||||
expect(call).not.toHaveProperty("refName");
|
|
||||||
expect(call).not.toHaveProperty("action");
|
|
||||||
expect(call).not.toHaveProperty("githubPrNumber");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens the new workspace draft tab as soon as the worktree is ready", async () => {
|
|
||||||
initialDraftState.text = "please review this change";
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("test-composer-submit"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
|
|
||||||
expect(firstCreateWorktreeCall().firstAgentContext?.prompt).toBe("please review this change");
|
|
||||||
expect(mockClient.createAgent).not.toHaveBeenCalled();
|
|
||||||
expect(saveDraftInputMock).toHaveBeenCalledWith({
|
|
||||||
draftKey: "draft:server:draft-new-workspace",
|
|
||||||
draft: {
|
|
||||||
text: "please review this change",
|
|
||||||
attachments: [],
|
|
||||||
cwd: createdWorkspace.workspaceDirectory,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(queueDraftSubmissionMock).toHaveBeenCalledWith({
|
|
||||||
serverId: "server",
|
|
||||||
workspaceId: createdWorkspace.id,
|
|
||||||
draftId: "draft-new-workspace",
|
|
||||||
text: "please review this change",
|
|
||||||
attachments: [],
|
|
||||||
cwd: createdWorkspace.workspaceDirectory,
|
|
||||||
provider: "claude-code",
|
|
||||||
allowEmptyText: true,
|
|
||||||
});
|
|
||||||
expect(navigateMock).toHaveBeenCalledWith({
|
|
||||||
serverId: "server",
|
|
||||||
workspaceId: createdWorkspace.id,
|
|
||||||
target: { kind: "draft", draftId: "draft-new-workspace" },
|
|
||||||
navigationMethod: "replace",
|
|
||||||
});
|
|
||||||
expect(clearDraftInputMock).toHaveBeenCalledWith({
|
|
||||||
draftKey: "new-workspace:server:/repo",
|
|
||||||
lifecycle: "sent",
|
|
||||||
});
|
|
||||||
expect(navigateMock.mock.invocationCallOrder[0]).toBeLessThan(
|
|
||||||
clearDraftInputMock.mock.invocationCallOrder[0],
|
|
||||||
);
|
|
||||||
expect(document.querySelector("textarea")).toHaveProperty("disabled", true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the selected PR number, title, and PR icon in the picker trigger", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const trigger = await findByTestId("new-workspace-ref-picker-trigger");
|
|
||||||
expect(trigger.textContent).toContain(`#${prItem.number}`);
|
|
||||||
expect(trigger.textContent).toContain(prItem.title);
|
|
||||||
expect(trigger.textContent).not.toContain("feature/picker");
|
|
||||||
expect(trigger.querySelectorAll('[data-icon="GitPullRequest"]')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds the selected picker PR as a composer attachment pill", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const pills = queryAllByTestId("composer-github-attachment-pill");
|
|
||||||
expect(pills).toHaveLength(1);
|
|
||||||
expect(pills[0]?.textContent).toContain(`#${prItem.number}`);
|
|
||||||
expect(pills[0]?.textContent).toContain("Refactor picker");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces the picker-owned composer PR pill when another PR is selected", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItemB.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const pills = queryAllByTestId("composer-github-attachment-pill");
|
|
||||||
expect(pills).toHaveLength(1);
|
|
||||||
expect(pills[0]?.textContent).toContain(`#${prItemB.number}`);
|
|
||||||
expect(pills[0]?.textContent).toContain("Polish composer chip");
|
|
||||||
expect(pills[0]?.textContent).not.toContain(`#${prItem.number}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes the picker-owned PR pill when switching to a branch and preserves user-added chips", async () => {
|
|
||||||
initialAttachments.push({ kind: "github_issue", item: issueItem });
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-branch-dev"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const pills = queryAllByTestId("composer-github-attachment-pill");
|
|
||||||
expect(pills).toHaveLength(1);
|
|
||||||
expect(pills[0]?.textContent).toContain(`#${issueItem.number}`);
|
|
||||||
expect(pills[0]?.textContent).toContain(issueItem.title);
|
|
||||||
expect(pills[0]?.textContent).not.toContain(`#${prItem.number}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends action=branch-off with the branch name when a branch row is selected", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-branch-dev"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("test-composer-submit"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
|
|
||||||
const call = firstCreateWorktreeCall();
|
|
||||||
expect(call).toMatchObject({
|
|
||||||
cwd: "/repo",
|
|
||||||
worktreeSlug: "gentle-slug",
|
|
||||||
action: "branch-off",
|
|
||||||
refName: "dev",
|
|
||||||
});
|
|
||||||
expect(call).not.toHaveProperty("githubPrNumber");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends action=checkout with pr number + head ref when a github PR row is selected", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("test-composer-submit"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
|
|
||||||
const call = firstCreateWorktreeCall();
|
|
||||||
expect(call).toMatchObject({
|
|
||||||
cwd: "/repo",
|
|
||||||
worktreeSlug: "gentle-slug",
|
|
||||||
action: "checkout",
|
|
||||||
refName: "feature/picker",
|
|
||||||
githubPrNumber: 202,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends the selected PR attachment when creating the worktree", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("test-composer-submit"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const call = firstCreateWorktreeCall();
|
|
||||||
const attachments = call.firstAgentContext?.attachments ?? [];
|
|
||||||
expect(attachments).toHaveLength(1);
|
|
||||||
expect(attachments[0]).toMatchObject({ type: "github_pr", number: 202 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the selected PR attachment when the PR query refreshes after selection", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
mockClient.searchGitHub.mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
githubFeaturesEnabled: true,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
await queryClient?.invalidateQueries({
|
|
||||||
queryKey: ["new-workspace-github-prs", "server", "/repo"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("test-composer-submit"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const call = firstCreateWorktreeCall();
|
|
||||||
expect(call.firstAgentContext?.attachments).toHaveLength(1);
|
|
||||||
expect(call.firstAgentContext?.attachments?.[0]).toMatchObject({
|
|
||||||
type: "github_pr",
|
|
||||||
number: 202,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits PR rows from the picker when GitHub features are disabled", async () => {
|
|
||||||
mockClient.searchGitHub.mockResolvedValueOnce({
|
|
||||||
items: [],
|
|
||||||
githubFeaturesEnabled: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(queryByTestId(`new-workspace-ref-picker-pr-${prItem.number}`)).toBeNull();
|
|
||||||
expect((await findByTestId("new-workspace-ref-picker-branch-dev")).textContent).toContain(
|
|
||||||
"dev",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves and locks the composer and picker while worktree creation is pending, then unlocks on error", async () => {
|
|
||||||
initialDraftState.text = "please review this change";
|
|
||||||
const createWorktree = createDeferredPromise<{
|
|
||||||
workspace: typeof createdWorkspace;
|
|
||||||
error: null;
|
|
||||||
}>();
|
|
||||||
mockClient.createPaseoWorktree.mockImplementationOnce(async () => await createWorktree.promise);
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("test-composer-submit"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const textInput = document.querySelector('[aria-label="Message agent..."]');
|
|
||||||
const removeButton = document.querySelector(`[aria-label="Remove PR #${prItem.number}"]`);
|
|
||||||
expect(queryByTestId("test-composer")?.dataset.submitBehavior).toBe("preserve-and-lock");
|
|
||||||
expect(queryByTestId("test-composer")?.dataset.submitIcon).toBe("return");
|
|
||||||
expect(textInput).toHaveProperty("value", "please review this change");
|
|
||||||
expect(textInput).toHaveProperty("disabled", true);
|
|
||||||
expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull();
|
|
||||||
expect(removeButton).toHaveProperty("disabled", true);
|
|
||||||
expect(queryByTestId("message-input-attach-button")).toHaveProperty("disabled", true);
|
|
||||||
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", true);
|
|
||||||
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", true);
|
|
||||||
expect(clearDraftInputMock).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
createWorktree.reject(new Error("Create worktree failed"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(textInput).toHaveProperty("value", "please review this change");
|
|
||||||
expect(textInput).toHaveProperty("disabled", false);
|
|
||||||
expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull();
|
|
||||||
expect(removeButton).toHaveProperty("disabled", false);
|
|
||||||
expect(queryByTestId("message-input-attach-button")).toHaveProperty("disabled", false);
|
|
||||||
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", false);
|
|
||||||
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -31,6 +31,7 @@ import type { ImageAttachment, MessagePayload } from "@/components/message-input
|
|||||||
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
|
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
|
||||||
|
import { syncPickerPrAttachment } from "./new-workspace-picker-state";
|
||||||
|
|
||||||
interface NewWorkspaceScreenProps {
|
interface NewWorkspaceScreenProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -194,36 +195,6 @@ function pickerItemTriggerLabel(item: PickerItem): string {
|
|||||||
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
|
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncPickerPrAttachment(input: {
|
|
||||||
attachments: UserComposerAttachment[];
|
|
||||||
previousPickerPrNumber: number | null;
|
|
||||||
item: PickerItem;
|
|
||||||
}): { attachments: UserComposerAttachment[]; attachedPrNumber: number | null } {
|
|
||||||
let nextAttachments = input.attachments;
|
|
||||||
let attachedPrNumber: number | null = null;
|
|
||||||
|
|
||||||
if (input.previousPickerPrNumber !== null) {
|
|
||||||
nextAttachments = nextAttachments.filter(
|
|
||||||
(attachment) =>
|
|
||||||
attachment.kind !== "github_pr" || attachment.item.number !== input.previousPickerPrNumber,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.item.kind === "github-pr") {
|
|
||||||
const selectedPr = input.item.item;
|
|
||||||
const hasExistingPrAttachment = nextAttachments.some(
|
|
||||||
(attachment) =>
|
|
||||||
attachment.kind === "github_pr" && attachment.item.number === selectedPr.number,
|
|
||||||
);
|
|
||||||
if (!hasExistingPrAttachment) {
|
|
||||||
nextAttachments = [...nextAttachments, { kind: "github_pr", item: selectedPr }];
|
|
||||||
attachedPrNumber = selectedPr.number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { attachments: nextAttachments, attachedPrNumber };
|
|
||||||
}
|
|
||||||
|
|
||||||
function computePickerOptionData(
|
function computePickerOptionData(
|
||||||
branchDetails: ReadonlyArray<{ name: string; committerDate: number }>,
|
branchDetails: ReadonlyArray<{ name: string; committerDate: number }>,
|
||||||
prItems: ReadonlyArray<GitHubSearchItem>,
|
prItems: ReadonlyArray<GitHubSearchItem>,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,158 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { AgentHistoryResult } from "@/hooks/use-agent-history";
|
|
||||||
import { SessionsScreen } from "@/screens/sessions-screen";
|
|
||||||
|
|
||||||
const { historyResult, navigate } = vi.hoisted(() => ({
|
|
||||||
historyResult: {
|
|
||||||
current: null as AgentHistoryResult | null,
|
|
||||||
},
|
|
||||||
navigate: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native", () => ({
|
|
||||||
View: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) =>
|
|
||||||
React.createElement("div", props, children),
|
|
||||||
Text: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) =>
|
|
||||||
React.createElement("span", props, children),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => {
|
|
||||||
const theme = {
|
|
||||||
spacing: { 4: 16, 6: 24 },
|
|
||||||
fontSize: { lg: 18 },
|
|
||||||
colors: {
|
|
||||||
surface0: "#111",
|
|
||||||
foregroundMuted: "#999",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ theme }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@react-navigation/native", () => ({
|
|
||||||
useIsFocused: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("expo-router", () => ({
|
|
||||||
router: {
|
|
||||||
navigate,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => ({
|
|
||||||
ChevronLeft: () => React.createElement("span", { "data-icon": "ChevronLeft" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/headers/menu-header", () => ({
|
|
||||||
MenuHeader: ({ title }: { title: string }) =>
|
|
||||||
React.createElement("header", { "data-testid": "menu-header" }, title),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/button", () => ({
|
|
||||||
Button: ({ children, onPress }: React.PropsWithChildren<{ onPress?: () => void }>) =>
|
|
||||||
React.createElement("button", { type: "button", onClick: onPress }, children),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/loading-spinner", () => ({
|
|
||||||
LoadingSpinner: ({ color, size }: { color: string; size?: string }) =>
|
|
||||||
React.createElement("div", {
|
|
||||||
"data-color": color,
|
|
||||||
"data-size": size,
|
|
||||||
"data-testid": "sessions-loading-spinner",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/agent-list", () => ({
|
|
||||||
AgentList: ({ agents }: { agents: unknown[] }) =>
|
|
||||||
React.createElement("div", { "data-agent-count": agents.length, "data-testid": "agent-list" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-agent-history", () => ({
|
|
||||||
useAgentHistory: () => {
|
|
||||||
if (!historyResult.current) {
|
|
||||||
throw new Error("Expected history result");
|
|
||||||
}
|
|
||||||
return historyResult.current;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/host-routes", () => ({
|
|
||||||
buildHostOpenProjectRoute: (serverId: string) => `/h/${serverId}/open-project`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function makeHistoryResult(overrides: Partial<AgentHistoryResult> = {}): AgentHistoryResult {
|
|
||||||
return {
|
|
||||||
agents: [],
|
|
||||||
isLoading: false,
|
|
||||||
isInitialLoad: false,
|
|
||||||
isRevalidating: false,
|
|
||||||
hasMore: false,
|
|
||||||
isLoadingMore: false,
|
|
||||||
refreshAll: vi.fn(),
|
|
||||||
loadMore: vi.fn(),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("SessionsScreen", () => {
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
let root: Root | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
navigate.mockReset();
|
|
||||||
historyResult.current = makeHistoryResult();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
historyResult.current = null;
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the shared loader during the initial React Query history load", () => {
|
|
||||||
historyResult.current = makeHistoryResult({ isInitialLoad: true, isLoading: true });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(<SessionsScreen serverId="server-1" />);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="sessions-loading-spinner"]')).not.toBeNull();
|
|
||||||
expect(container?.textContent).not.toContain("No sessions yet");
|
|
||||||
expect(container?.textContent).not.toContain("Back");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the empty state after history finishes loading with no sessions", () => {
|
|
||||||
historyResult.current = makeHistoryResult();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(<SessionsScreen serverId="server-1" />);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="sessions-loading-spinner"]')).toBeNull();
|
|
||||||
expect(container?.textContent).toContain("No sessions yet");
|
|
||||||
expect(container?.textContent).toContain("Back");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import type { ComposerAttachment } from "@/attachments/types";
|
|
||||||
import type { MessagePayload } from "@/components/message-input";
|
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
|
||||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
|
||||||
import type { StreamItem } from "@/types/stream";
|
|
||||||
import { WorkspaceDraftAgentTab } from "./workspace-draft-agent-tab";
|
|
||||||
|
|
||||||
const {
|
|
||||||
createAgentMock,
|
|
||||||
getCheckoutStatusMock,
|
|
||||||
onRuntimeEventMock,
|
|
||||||
latestComposerText,
|
|
||||||
latestComposerDisabled,
|
|
||||||
latestStreamText,
|
|
||||||
onCreatedMock,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
createAgentMock: vi.fn(),
|
|
||||||
getCheckoutStatusMock: vi.fn(),
|
|
||||||
onRuntimeEventMock: vi.fn(() => () => {}),
|
|
||||||
latestComposerText: { current: null as string | null },
|
|
||||||
latestComposerDisabled: { current: null as boolean | null },
|
|
||||||
latestStreamText: { current: null as string | null },
|
|
||||||
onCreatedMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) =>
|
|
||||||
typeof factory === "function"
|
|
||||||
? factory({
|
|
||||||
colors: {
|
|
||||||
surface0: "#000",
|
|
||||||
surface2: "#222",
|
|
||||||
destructive: "#f00",
|
|
||||||
},
|
|
||||||
spacing: { 2: 8, 3: 12, 4: 16, 6: 24 },
|
|
||||||
borderRadius: { md: 6 },
|
|
||||||
fontSize: { sm: 13 },
|
|
||||||
})
|
|
||||||
: factory,
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ rt: { breakpoint: "lg" } }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-safe-area-context", () => ({
|
|
||||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/constants/platform", () => ({
|
|
||||||
isWeb: true,
|
|
||||||
isNative: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
|
||||||
useHostRuntimeClient: () => ({
|
|
||||||
createAgent: createAgentMock,
|
|
||||||
getCheckoutStatus: getCheckoutStatusMock,
|
|
||||||
on: onRuntimeEventMock,
|
|
||||||
}),
|
|
||||||
useHostRuntimeIsConnected: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/session-store-hooks", () => ({
|
|
||||||
useWorkspaceExecutionAuthority: () => ({
|
|
||||||
ok: true,
|
|
||||||
authority: {
|
|
||||||
workspaceId: "workspace-1",
|
|
||||||
workspaceDirectory: "/repo/.paseo/worktrees/workspace-1",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-agent-input-draft", () => ({
|
|
||||||
useAgentInputDraft: () => {
|
|
||||||
const [text, setText] = React.useState("please review this change");
|
|
||||||
const [attachments, setAttachments] = React.useState<ComposerAttachment[]>([]);
|
|
||||||
return {
|
|
||||||
text,
|
|
||||||
setText,
|
|
||||||
attachments,
|
|
||||||
setAttachments,
|
|
||||||
cwd: "/repo/.paseo/worktrees/workspace-1",
|
|
||||||
clear: () => {
|
|
||||||
setText("");
|
|
||||||
setAttachments([]);
|
|
||||||
},
|
|
||||||
isHydrated: true,
|
|
||||||
composerState: {
|
|
||||||
providerDefinitions: [{ id: "codex", label: "Codex" }],
|
|
||||||
selectedProvider: "codex",
|
|
||||||
selectedMode: "",
|
|
||||||
modeOptions: [],
|
|
||||||
selectedModel: "gpt-5.4",
|
|
||||||
availableModels: [{ id: "gpt-5.4", label: "GPT-5.4" }],
|
|
||||||
allProviderModels: {},
|
|
||||||
isAllModelsLoading: false,
|
|
||||||
isModelLoading: false,
|
|
||||||
effectiveModelId: "gpt-5.4",
|
|
||||||
effectiveThinkingOptionId: "",
|
|
||||||
featureValues: undefined,
|
|
||||||
statusControls: {
|
|
||||||
providerDefinitions: [{ id: "codex", label: "Codex" }],
|
|
||||||
selectedProvider: "codex",
|
|
||||||
modeOptions: [],
|
|
||||||
selectedMode: "",
|
|
||||||
models: [{ id: "gpt-5.4", label: "GPT-5.4" }],
|
|
||||||
selectedModel: "gpt-5.4",
|
|
||||||
thinkingOptions: [],
|
|
||||||
selectedThinkingOptionId: "",
|
|
||||||
isModelLoading: false,
|
|
||||||
allProviderModels: {},
|
|
||||||
isAllModelsLoading: false,
|
|
||||||
features: [],
|
|
||||||
},
|
|
||||||
commandDraftConfig: undefined,
|
|
||||||
persistFormPreferences: vi.fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/encode-images", () => ({
|
|
||||||
encodeImages: vi.fn(async () => []),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/composer-attachments", () => ({
|
|
||||||
splitComposerAttachmentsForSubmit: (attachments: ComposerAttachment[]) => ({
|
|
||||||
images: [],
|
|
||||||
attachments,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/attachments/composer-workspace-attachments", () => ({
|
|
||||||
composerWorkspaceAttachment: {
|
|
||||||
userAttachmentsOnly: (attachments: ComposerAttachment[]) =>
|
|
||||||
attachments.filter(
|
|
||||||
(attachment): attachment is Exclude<ComposerAttachment, { kind: "review" }> =>
|
|
||||||
attachment.kind !== "review",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/composer", () => ({
|
|
||||||
Composer: ({
|
|
||||||
value,
|
|
||||||
isSubmitLoading,
|
|
||||||
}: {
|
|
||||||
onSubmitMessage: (payload: MessagePayload) => Promise<void>;
|
|
||||||
value: string;
|
|
||||||
isSubmitLoading?: boolean;
|
|
||||||
}) => {
|
|
||||||
latestComposerText.current = value;
|
|
||||||
latestComposerDisabled.current = isSubmitLoading ?? false;
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
aria-label="Message agent..."
|
|
||||||
data-testid="composer"
|
|
||||||
disabled={isSubmitLoading}
|
|
||||||
readOnly
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/agent-stream-view", () => ({
|
|
||||||
AgentStreamView: ({ streamItems }: { streamItems: StreamItem[] }) => {
|
|
||||||
latestStreamText.current =
|
|
||||||
streamItems.find((item) => item.kind === "user_message")?.text ?? null;
|
|
||||||
return <div data-testid="agent-stream-view">{latestStreamText.current}</div>;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/file-drop-zone", () => ({
|
|
||||||
FileDropZone: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function createDeferredPromise<T>() {
|
|
||||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
||||||
let reject!: (reason?: unknown) => void;
|
|
||||||
const promise = new Promise<T>((nextResolve, nextReject) => {
|
|
||||||
resolve = nextResolve;
|
|
||||||
reject = nextReject;
|
|
||||||
});
|
|
||||||
return { promise, resolve, reject };
|
|
||||||
}
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
let queryClient: QueryClient | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
vi.stubGlobal(
|
|
||||||
"ResizeObserver",
|
|
||||||
class ResizeObserver {
|
|
||||||
observe() {}
|
|
||||||
unobserve() {}
|
|
||||||
disconnect() {}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
createAgentMock.mockReset();
|
|
||||||
getCheckoutStatusMock.mockResolvedValue({
|
|
||||||
cwd: "/repo/.paseo/worktrees/workspace-1",
|
|
||||||
error: null,
|
|
||||||
requestId: "checkout-status-1",
|
|
||||||
isGit: false,
|
|
||||||
isPaseoOwnedWorktree: true,
|
|
||||||
repoRoot: null,
|
|
||||||
currentBranch: null,
|
|
||||||
isDirty: false,
|
|
||||||
baseRef: null,
|
|
||||||
aheadBehind: null,
|
|
||||||
aheadOfOrigin: null,
|
|
||||||
behindOfOrigin: null,
|
|
||||||
hasRemote: false,
|
|
||||||
remoteUrl: null,
|
|
||||||
});
|
|
||||||
onRuntimeEventMock.mockClear();
|
|
||||||
onCreatedMock.mockReset();
|
|
||||||
latestComposerText.current = null;
|
|
||||||
latestComposerDisabled.current = null;
|
|
||||||
latestStreamText.current = null;
|
|
||||||
useCreateFlowStore.getState().clearAll();
|
|
||||||
useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
queryClient?.clear();
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
queryClient = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function flush() {
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDraftTab() {
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<QueryClientProvider client={queryClient!}>
|
|
||||||
<WorkspaceDraftAgentTab
|
|
||||||
serverId="server"
|
|
||||||
workspaceId="workspace-1"
|
|
||||||
tabId="draft-1"
|
|
||||||
draftId="draft-1"
|
|
||||||
isPaneFocused={true}
|
|
||||||
onCreated={onCreatedMock}
|
|
||||||
onOpenWorkspaceFile={vi.fn()}
|
|
||||||
/>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("WorkspaceDraftAgentTab", () => {
|
|
||||||
it("clears the composer while an auto-submitted new-workspace prompt is shown in the stream", async () => {
|
|
||||||
const createAgent = createDeferredPromise<{ id: string }>();
|
|
||||||
createAgentMock.mockImplementationOnce(async () => await createAgent.promise);
|
|
||||||
useWorkspaceDraftSubmissionStore.getState().setPending({
|
|
||||||
serverId: "server",
|
|
||||||
workspaceId: "workspace-1",
|
|
||||||
draftId: "draft-1",
|
|
||||||
text: "please review this change",
|
|
||||||
attachments: [],
|
|
||||||
cwd: "/repo/.paseo/worktrees/workspace-1",
|
|
||||||
provider: "codex",
|
|
||||||
model: "gpt-5.4",
|
|
||||||
allowEmptyText: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderDraftTab();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(createAgentMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(latestStreamText.current).toBe("please review this change");
|
|
||||||
expect(latestComposerDisabled.current).toBe(true);
|
|
||||||
expect(latestComposerText.current).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user