Open History to see the last commit
1import * as NodeModule from "node:module";
2import { describe, expect, it } from "vite-plus/test";
3import {
4 androidWorkspaceTabNavigationState,
5 type AndroidWorkspaceNavigationTab,
6} from "../features/layout/workspace/android-workspace-tabs";
7import {
8 linkedThreadStep,
9 parentThreadIsBelow,
10 resetAndroidWorkspace,
11 returnToAndroidWorkspaceList,
12} from "./android-workspace-navigation";
13import type { RootStackResetAction } from "./root-stack-reset";
14
15const require = NodeModule.createRequire(import.meta.url);
16const nativePackage = require.resolve("@react-navigation/native/package.json");
17const requireFromNative = NodeModule.createRequire(nativePackage);
18const corePackage = requireFromNative.resolve("@react-navigation/core/package.json");
19const requireFromCore = NodeModule.createRequire(corePackage);
20const { CommonActions, StackActions, StackRouter } = requireFromCore(
21 "@react-navigation/routers",
22) as {
23 readonly CommonActions: typeof import("@react-navigation/native").CommonActions;
24 readonly StackActions: typeof import("@react-navigation/native").StackActions;
25 readonly StackRouter: typeof import("@react-navigation/native").StackRouter;
26};
27const router = StackRouter({});
28const options = {
29 routeNames: [
30 "Home",
31 "Thread",
32 "ThreadFiles",
33 "ThreadTerminal",
34 "GitOverview",
35 "PullRequest",
36 "PullRequestFile",
37 "PullRequestReviewers",
38 "Settings",
39 "SettingsEnvironments",
40 "SettingsAppearance",
41 "SettingsOpenSourceLicenses",
42 "SettingsUsage",
43 "SettingsUsageAccount",
44 ],
45 routeParamList: {},
46 routeGetIdList: {},
47};
48const thread = {
49 name: "Thread",
50 params: { environmentId: "environment-a", threadId: "thread-a" },
51} as const;
52const pullRequest = {
53 name: "PullRequest",
54 params: {
55 environmentId: "environment-a",
56 projectId: "project-a",
57 repository: "owner/repository",
58 number: 42,
59 host: "github.com",
60 },
61} as const;
62
63function apply(
64 state: ReturnType<typeof router.getInitialState>,
65 action: Parameters<typeof router.getStateForAction>[1],
66) {
67 const result = router.getStateForAction(state, action, options);
68 if (!result) throw new Error(`Router rejected ${action.type}`);
69 return router.getRehydratedState(result, options);
70}
71
72function withThread() {
73 return apply(router.getInitialState(options), StackActions.push(thread.name, thread.params));
74}
75
76function legacyHistory() {
77 let state = router.getInitialState(options);
78 state = apply(state, StackActions.push(thread.name, thread.params));
79 state = apply(state, StackActions.push(pullRequest.name, pullRequest.params));
80 return state;
81}
82
83function names(state: ReturnType<typeof router.getInitialState>) {
84 return state.routes.map((route) => route.name);
85}
86
87describe("Android workspace navigation", () => {
88 it("returns to the previous page, otherwise to the workspace list", () => {
89 const parent = withThread();
90 let state = apply(
91 parent,
92 StackActions.push("Thread", { ...thread.params, threadId: "child-a" }),
93 );
94 const navigation = {
95 dispatch: (action: RootStackResetAction) => {
96 state = apply(state, action);
97 },
98 getParent: () => undefined,
99 getState: () => state,
100 goBack: () => {
101 state = apply(state, StackActions.pop());
102 },
103 };
104 const back = () => returnToAndroidWorkspaceList(navigation);
105
106 back();
107 expect(state.routes).toEqual(parent.routes);
108 expect(state.index).toBe(parent.index);
109
110 back();
111 expect(state.routes).toEqual([parent.routes[0]]);
112 expect(state.index).toBe(0);
113
114 state = apply(
115 parent,
116 StackActions.push("Thread", { ...thread.params, environmentId: "environment-b" }),
117 );
118 back();
119 expect(state.routes).toEqual(parent.routes);
120 expect(state.index).toBe(parent.index);
121
122 const pullRequestOverThread = legacyHistory();
123 state = pullRequestOverThread;
124 back();
125 expect(state.routes).toEqual(pullRequestOverThread.routes.slice(0, 2));
126
127 // Confirming a Git action from the inline inspector can duplicate the current thread.
128 state = apply(parent, StackActions.push("Thread", thread.params));
129 back();
130 expect({ routes: names(state), index: state.index }).toEqual({ routes: ["Home"], index: 0 });
131
132 const child = { ...thread.params, threadId: "child-a" };
133 state = apply(
134 apply(parent, StackActions.push("Thread", child)),
135 StackActions.push("Thread", child),
136 );
137 back();
138 expect(state.routes).toEqual(parent.routes);
139 });
140
141 it("opens a linked thread above the current one so Back returns to it", () => {
142 const parent = withThread();
143 const child = { ...thread.params, threadId: "child-a" };
144 const open = (
145 state: ReturnType<typeof router.getInitialState>,
146 target: ReactNavigation.RootParamList["Thread"],
147 ) => {
148 const step = linkedThreadStep(state, target);
149 if (step === "stay") return state;
150 return apply(
151 state,
152 step === "pop" ? StackActions.pop() : StackActions.push("Thread", target),
153 );
154 };
155
156 const inChild = open(parent, child);
157 expect(inChild.routes.map((route) => route.params)).toEqual([undefined, thread.params, child]);
158 expect(open(inChild, child)).toBe(inChild);
159
160 const backInParent = open(inChild, thread.params);
161 expect(backInParent.routes).toEqual(parent.routes);
162 expect(backInParent.index).toBe(parent.index);
163 });
164
165 it("recognizes a parent only when its environment and thread are immediately below", () => {
166 const parent = withThread();
167 const child = apply(
168 parent,
169 StackActions.push("Thread", { ...thread.params, threadId: "child-a" }),
170 );
171
172 expect(parentThreadIsBelow(child, thread.params)).toBe(true);
173 expect(parentThreadIsBelow(child, { ...thread.params, environmentId: "environment-b" })).toBe(
174 false,
175 );
176 expect(parentThreadIsBelow(child, { ...thread.params, threadId: "other" })).toBe(false);
177 expect(parentThreadIsBelow(parent, thread.params)).toBe(false);
178 expect(
179 parentThreadIsBelow(
180 apply(child, StackActions.push("Thread", { ...thread.params, threadId: "grandchild-a" })),
181 thread.params,
182 ),
183 ).toBe(false);
184 });
185
186 it("resets the root stack when a nested stack has focus", () => {
187 const root = apply(router.getInitialState(options), StackActions.push("Thread", thread.params));
188 const nested = router.getInitialState({ ...options, routeNames: ["Nested", "NestedChild"] });
189 const actions: RootStackResetAction[] = [];
190 const rootNavigation = {
191 dispatch: () => undefined,
192 getParent: () => undefined,
193 getState: () => root,
194 };
195 resetAndroidWorkspace({
196 dispatch: (action) => actions.push(action),
197 getParent: () => rootNavigation,
198 getState: () => nested,
199 });
200 const [action] = actions;
201 expect(action?.target).toBe(root.key);
202 expect(names(apply(root, action!))).toEqual(["Home"]);
203 });
204
205 it("resets from the navigation container before any navigation action", () => {
206 const root = router.getInitialState(options);
207 const actions: RootStackResetAction[] = [];
208 // The container only learns the root state after the first action.
209 resetAndroidWorkspace({
210 dispatch: (action) => actions.push(action),
211 getParent: () => undefined,
212 getState: () => undefined,
213 getRootState: () => root,
214 });
215 const [action] = actions;
216 expect(action?.target).toBe(root.key);
217 expect(names(apply(root, action!))).toEqual(["Home"]);
218 });
219});
220
221describe("Android Settings tab", () => {
222 type State = ReturnType<typeof router.getInitialState>;
223 type Tab = AndroidWorkspaceNavigationTab;
224 const openTab = (state: State, tab: Tab, columns = false) =>
225 apply(state, CommonActions.reset(androidWorkspaceTabNavigationState(state, tab, columns)));
226
227 it("opens a default Settings page beside the category list in wide layouts", () => {
228 const settings = openTab(legacyHistory(), "settings", true);
229 expect(names(settings)).toEqual(["Home", "Settings", "SettingsEnvironments"]);
230 expect(names(apply(settings, StackActions.pop()))).toEqual(["Home", "Settings"]);
231 });
232
233 it("keeps the selected category mounted when reselecting wide Settings", () => {
234 let settings = openTab(legacyHistory(), "settings");
235 settings = apply(settings, StackActions.push("SettingsAppearance"));
236 const category = settings.routes.at(-1);
237 settings = apply(settings, StackActions.push("SettingsOpenSourceLicenses"));
238 const reselected = openTab(settings, "settings", true);
239 expect(names(reselected)).toEqual(["Home", "Settings", "SettingsAppearance"]);
240 expect(reselected.routes.at(-1)?.key).toBe(category?.key);
241 });
242
243 it("opens over Home alone, dropping chat and PR history, and Back returns to Home", () => {
244 const previous = legacyHistory();
245 const settings = openTab(previous, "settings");
246 expect(names(settings)).toEqual(["Home", "Settings"]);
247 expect(settings.routes[0]?.key).toBe(previous.routes[0]?.key);
248 const back = apply(settings, StackActions.pop());
249 expect(names(back)).toEqual(["Home"]);
250 expect(back.routes[0]?.key).toBe(previous.routes[0]?.key);
251 });
252
253 it("stays selected under a settings page until a list tab returns to Home", () => {
254 const previous = legacyHistory();
255 const usage = apply(openTab(previous, "settings"), StackActions.push("SettingsUsage"));
256 const list = openTab(usage, "pullRequests");
257 expect(names(list)).toEqual(["Home"]);
258 expect(list.routes[0]?.key).toBe(previous.routes[0]?.key);
259 });
260});