mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +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 { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
|
||||
import { syncPickerPrAttachment } from "./new-workspace-picker-state";
|
||||
|
||||
interface NewWorkspaceScreenProps {
|
||||
serverId: string;
|
||||
@@ -194,36 +195,6 @@ function pickerItemTriggerLabel(item: PickerItem): string {
|
||||
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(
|
||||
branchDetails: ReadonlyArray<{ name: string; committerDate: number }>,
|
||||
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