1// @vitest-environment jsdom
2import type { VcsStatusResult } from "@t3tools/contracts";
3import { act } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, describe, expect, it, vi } from "vite-plus/test";
6
7// Native header item factories are only read when options are applied, so the fake
8// navigation snapshots the right items at each setOptions, as native-stack does.
9const harness = vi.hoisted(() => ({
10 setOptionsCalls: 0,
11 renderedRightItems: [] as Array<ReadonlyArray<Record<string, unknown>>>,
12 navigatedRoutes: [] as string[],
13}));
14
15vi.mock("@react-navigation/native", () => {
16 const navigation = {
17 canGoBack: () => true,
18 dispatch: () => {},
19 goBack: () => {},
20 navigate: (route: string) => {
21 harness.navigatedRoutes.push(route);
22 },
23 addListener: () => () => {},
24 setOptions: (options: { unstable_headerRightItems?: () => Array<Record<string, unknown>> }) => {
25 harness.setOptionsCalls += 1;
26 harness.renderedRightItems.push(options.unstable_headerRightItems?.() ?? []);
27 },
28 };
29 return {
30 StackActions: { replace: () => ({}) },
31 useNavigation: () => navigation,
32 };
33});
34vi.mock("../../native/useOpenPullRequest", () => ({ useOpenPullRequest: () => () => {} }));
35
36vi.mock("../../state/session", () => ({ useEnvironmentScope: () => true }));
37vi.mock("react-native", () => ({ Alert: { alert: () => {} }, Linking: {}, Platform: {} }));
38vi.mock("../layout/AdaptiveWorkspaceLayout", () => ({
39 useAdaptiveWorkspaceLayout: () => ({
40 layout: { usesSplitView: false },
41 panes: { primarySidebarVisible: false, auxiliaryPaneVisible: false },
42 togglePrimarySidebar: () => {},
43 toggleAuxiliaryPane: () => {},
44 }),
45}));
46vi.mock("../layout/native-mail-search-toolbar", () => ({
47 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED: true,
48 createNativeMailSearchToolbarItem: () => ({}),
49}));
50vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({
51 useAppearancePreferences: () => ({ themeVariables: {} }),
52}));
53
54import { ThreadHeader } from "./ThreadHeader";
55
56const projectScripts: never[] = [];
57
58function status(
59 files: ReadonlyArray<string>,
60 overrides: Partial<VcsStatusResult> = {},
61): VcsStatusResult {
62 return {
63 isRepo: true,
64 hasPrimaryRemote: false,
65 isDefaultRef: true,
66 refName: "master",
67 hasWorkingTreeChanges: files.length > 0,
68 workingTree: {
69 files: files.map((path) => ({ path, insertions: 0, deletions: 0 })),
70 insertions: 0,
71 deletions: 0,
72 },
73 hasUpstream: false,
74 aheadCount: 0,
75 behindCount: 0,
76 pr: null,
77 ...overrides,
78 } as unknown as VcsStatusResult;
79}
80
81// Mirrors the route screen: every render passes fresh gitControls callbacks.
82function Header(props: { readonly gitStatus: VcsStatusResult | null; readonly title?: string }) {
83 return (
84 <ThreadHeader
85 title={props.title ?? "Thread"}
86 subtitle=""
87 headerColor="#000"
88 usesNativeHeaderGlass
89 gitControls={{
90 environmentId: "environment-1",
91 threadId: "thread-1",
92 currentBranch: "master",
93 gitStatus: props.gitStatus,
94 gitOperationLabel: null,
95 onPull: async () => {},
96 onRunAction: async () => null,
97 canOpenTerminal: true,
98 canOperateTerminal: true,
99 canOpenFiles: true,
100 projectScripts,
101 terminalSessions: [],
102 onOpenTerminal: () => {},
103 onOpenNewTerminal: () => {},
104 onRunProjectScript: async () => {},
105 }}
106 hasThreadCwd
107 hasWorkspaceRoot
108 fileInspectorSupported={false}
109 inspectorMode={null}
110 onToggleInspector={() => {}}
111 onOpenGitInspector={() => {}}
112 onOpenFilesInspector={() => {}}
113 />
114 );
115}
116
117function renderedGitMenuItems(): Array<{
118 description?: unknown;
119 label?: unknown;
120 onPress?: () => void;
121}> {
122 const items = harness.renderedRightItems.at(-1) ?? [];
123 const git = items.find((item) => item.identifier === "thread-right-git") as
124 | { menu: { items: Array<{ description?: unknown; label?: unknown; onPress?: () => void }> } }
125 | undefined;
126 return git?.menu.items ?? [];
127}
128
129function renderedGitStatusDescription(): unknown {
130 return renderedGitMenuItems()[0]?.description;
131}
132
133let root: Root | null = null;
134
135function render(element: React.ReactElement) {
136 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
137 root ??= createRoot(document.createElement("div"));
138 act(() => root!.render(element));
139}
140
141afterEach(() => {
142 act(() => root?.unmount());
143 root = null;
144 harness.setOptionsCalls = 0;
145 harness.renderedRightItems = [];
146 harness.navigatedRoutes = [];
147});
148
149describe("ThreadHeader", () => {
150 it("re-applies native header items when Git status changes", () => {
151 render(<Header gitStatus={status([])} />);
152 expect(renderedGitStatusDescription()).toBe("Clean");
153
154 // A refreshed status (e.g. after reconnect) must reach the Git popover even
155 // though nothing else in the header changed.
156 render(<Header gitStatus={status(["audit.txt"])} />);
157 expect(renderedGitStatusDescription()).toBe("1 changed");
158
159 render(<Header gitStatus={status([])} />);
160 expect(renderedGitStatusDescription()).toBe("Clean");
161 });
162
163 it("does not re-apply options for re-renders with equivalent header content", () => {
164 render(<Header gitStatus={status([])} />);
165 const applied = harness.setOptionsCalls;
166
167 // Streaming re-renders the route with fresh callbacks and equal Git content.
168 for (let index = 0; index < 20; index += 1) {
169 render(<Header gitStatus={status([])} />);
170 }
171 expect(harness.setOptionsCalls).toBe(applied);
172 });
173
174 it("re-applies menu actions when the default ref changes behind the same label", async () => {
175 // An unpushed commit on a branch with an open PR shows "Push" whether or not the
176 // branch is the default one, but only the default branch asks for confirmation.
177 const pushable = (isDefaultRef: boolean) =>
178 status([], {
179 isDefaultRef,
180 aheadCount: 1,
181 hasPrimaryRemote: true,
182 pr: { state: "open", url: "https://example.com/pr/1" },
183 } as Partial<VcsStatusResult>);
184 render(<Header gitStatus={pushable(false)} />);
185 expect(renderedGitMenuItems()[1]?.label).toBe("Push");
186
187 render(<Header gitStatus={pushable(true)} />);
188 expect(renderedGitMenuItems()[1]?.label).toBe("Push");
189 await act(async () => renderedGitMenuItems()[1]?.onPress?.());
190 expect(harness.navigatedRoutes).toEqual(["GitConfirm"]);
191 });
192});