1import { isValidElement, type ReactNode } from "react";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4const state = vi.hoisted(() => ({
5 fields: [] as string[],
6 fieldIndex: 0,
7 canChangeThreadBranch: true,
8 navigations: 0,
9 result: Promise.resolve(null) as Promise<unknown>,
10}));
11
12vi.mock("react", async (importOriginal) => ({
13 ...(await importOriginal<typeof import("react")>()),
14 useState: (initial: string) => {
15 const index = state.fieldIndex++;
16 state.fields[index] ??= initial;
17 return [state.fields[index], (value: string) => (state.fields[index] = value)];
18 },
19}));
20vi.mock("react-native", () => ({
21 Platform: { OS: "ios" },
22 useWindowDimensions: () => ({ height: 800, width: 400 }),
23 Pressable: "Pressable",
24 ScrollView: "ScrollView",
25 View: "View",
26}));
27vi.mock("@react-navigation/native", () => ({
28 useNavigation: () => ({ goBack: () => state.navigations++ }),
29}));
30vi.mock("react-native-safe-area-context", () => ({
31 useSafeAreaInsets: () => ({ bottom: 0 }),
32}));
33vi.mock("../../../components/AndroidScreenHeader", () => ({
34 AndroidSheetHeader: "AndroidSheetHeader",
35}));
36vi.mock("../../../components/AppText", () => ({ AppText: "Text", AppTextInput: "TextInput" }));
37vi.mock("./gitSheetComponents", () => ({ SheetActionButton: "SheetActionButton" }));
38vi.mock("../../../state/query", () => ({
39 useEnvironmentQuery: () => ({ data: { refName: "main" } }),
40}));
41vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { status: () => null } }));
42vi.mock("../../../state/use-thread-selection", () => ({
43 useThreadSelection: () => ({ selectedThread: { environmentId: "environment", branch: "main" } }),
44}));
45vi.mock("../../../state/use-selected-thread-worktree", () => ({
46 useSelectedThreadWorktree: () => ({
47 selectedThreadCwd: "/repo",
48 selectedThreadWorktreePath: null,
49 }),
50}));
51vi.mock("../../../state/use-selected-thread-git-state", () => ({
52 useSelectedThreadGitState: () => ({
53 selectedThreadBranches: [{ name: "main", current: true, isDefault: true, worktreePath: null }],
54 selectedThreadBranchesLoading: false,
55 gitOperationLabel: null,
56 }),
57}));
58vi.mock("../../../state/use-selected-thread-git-actions", () => ({
59 useSelectedThreadGitActions: () => ({
60 canChangeThreadBranch: state.canChangeThreadBranch,
61 onCreateSelectedThreadBranch: () => state.result,
62 onCreateSelectedThreadWorktree: () => state.result,
63 onCheckoutSelectedThreadBranch: () => state.result,
64 }),
65}));
66
67import { GitBranchesSheet } from "./GitBranchesSheet";
68
69type ControlProps = {
70 label?: string;
71 placeholder?: string;
72 onPress?: () => unknown;
73 onChangeText?: (value: string) => void;
74 value?: string;
75 children?: ReactNode;
76};
77
78function findControl(node: ReactNode, label: string): ControlProps | null {
79 if (Array.isArray(node)) {
80 for (const child of node) {
81 const control = findControl(child, label);
82 if (control) return control;
83 }
84 return null;
85 }
86 if (!isValidElement<ControlProps>(node)) return null;
87 if (
88 node.props.label === label ||
89 node.props.placeholder === label ||
90 (label === "checkout" && node.type === "Pressable")
91 ) {
92 return node.props;
93 }
94 return findControl(node.props.children, label);
95}
96
97function control(label: string) {
98 state.fieldIndex = 0;
99 const found = findControl(
100 GitBranchesSheet({ route: { params: { environmentId: "environment", threadId: "thread" } } }),
101 label,
102 );
103 if (!found) throw new Error(`Missing sheet control: ${label}`);
104 return found;
105}
106
107function deferred<T>() {
108 let resolve!: (value: T) => void;
109 const promise = new Promise<T>((complete) => {
110 resolve = complete;
111 });
112 return { promise, resolve };
113}
114
115describe("branch sheet operation completion", () => {
116 beforeEach(() => {
117 state.fields = [];
118 state.fieldIndex = 0;
119 state.canChangeThreadBranch = true;
120 state.navigations = 0;
121 control("feature/mobile-polish").onChangeText?.("feature/branch");
122 control("main").onChangeText?.("release");
123 control("feature/mobile-thread").onChangeText?.("feature/worktree");
124 });
125
126 it.each(["Create & checkout", "Create worktree", "checkout"])(
127 "keeps the sheet and its input when %s cannot complete",
128 async (operation) => {
129 const result = deferred<null>();
130 state.result = result.promise;
131 const press = control(operation).onPress;
132 if (!press) throw new Error("Missing operation handler");
133 state.canChangeThreadBranch = false;
134 const completion = press();
135 result.resolve(null);
136 await result.promise;
137 await completion;
138
139 expect(state.navigations).toBe(0);
140 expect(control("feature/mobile-polish").value).toBe("feature/branch");
141 expect(control("main").value).toBe("release");
142 expect(control("feature/mobile-thread").value).toBe("feature/worktree");
143 },
144 );
145
146 it.each(["Create & checkout", "Create worktree", "checkout"])(
147 "dismisses after accepted %s completion even if the source grant changes",
148 async (operation) => {
149 const result = deferred<unknown>();
150 state.result = result.promise;
151 const press = control(operation).onPress;
152 if (!press) throw new Error("Missing operation handler");
153 const completion = press();
154 state.canChangeThreadBranch = false;
155 result.resolve(
156 operation === "Create worktree"
157 ? { worktree: { path: "/repo-worktree", refName: "feature/worktree" } }
158 : { refName: operation === "checkout" ? "main" : "feature/branch" },
159 );
160 await result.promise;
161 await completion;
162
163 expect(state.navigations).toBe(1);
164 expect(control("feature/mobile-polish").value).toBe(
165 operation === "Create & checkout" ? "" : "feature/branch",
166 );
167 expect(control("feature/mobile-thread").value).toBe(
168 operation === "Create worktree" ? "" : "feature/worktree",
169 );
170 },
171 );
172});