1import * as NodeModule from "node:module";
2import { describe, expect, it } from "vite-plus/test";
3import {
4 androidWorkspaceNavigationState,
5 type AndroidWorkspaceDestination,
6} from "./android-workspace-navigation";
7
8const require = NodeModule.createRequire(import.meta.url);
9const nativePackage = require.resolve("@react-navigation/native/package.json");
10const requireFromNative = NodeModule.createRequire(nativePackage);
11const corePackage = requireFromNative.resolve("@react-navigation/core/package.json");
12const requireFromCore = NodeModule.createRequire(corePackage);
13const { CommonActions, StackActions, StackRouter } = requireFromCore(
14 "@react-navigation/routers",
15) as {
16 readonly CommonActions: typeof import("@react-navigation/native").CommonActions;
17 readonly StackActions: typeof import("@react-navigation/native").StackActions;
18 readonly StackRouter: typeof import("@react-navigation/native").StackRouter;
19};
20const router = StackRouter({});
21const options = {
22 routeNames: [
23 "Home",
24 "Thread",
25 "ThreadFiles",
26 "ThreadTerminal",
27 "GitOverview",
28 "PullRequest",
29 "PullRequestFile",
30 "PullRequestReviewers",
31 "SettingsSheet",
32 ],
33 routeParamList: {},
34 routeGetIdList: {},
35};
36const thread = {
37 name: "Thread",
38 params: { environmentId: "environment-a", threadId: "thread-a" },
39} as const;
40const pullRequest = {
41 name: "PullRequest",
42 params: {
43 environmentId: "environment-a",
44 projectId: "project-a",
45 repository: "owner/repository",
46 number: 42,
47 host: "github.com",
48 },
49} as const;
50
51function apply(
52 state: ReturnType<typeof router.getInitialState>,
53 action: Parameters<typeof router.getStateForAction>[1],
54) {
55 const result = router.getStateForAction(state, action, options);
56 if (!result) throw new Error(`Router rejected ${action.type}`);
57 return router.getRehydratedState(result, options);
58}
59
60function select(
61 state: ReturnType<typeof router.getInitialState>,
62 destination?: AndroidWorkspaceDestination,
63 reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"],
64) {
65 return apply(
66 state,
67 CommonActions.reset(androidWorkspaceNavigationState(state, destination, reviewers)),
68 );
69}
70
71function legacyHistory() {
72 let state = router.getInitialState(options);
73 state = apply(state, StackActions.push(thread.name, thread.params));
74 state = apply(state, StackActions.push(pullRequest.name, pullRequest.params));
75 return state;
76}
77
78function names(state: ReturnType<typeof router.getInitialState>) {
79 return state.routes.map((route) => route.name);
80}
81
82describe("Android workspace navigation", () => {
83 it("replaces cross-tab history with one detail and returns directly to its list", () => {
84 const previous = legacyHistory();
85 const state = select(previous, pullRequest);
86 expect(names(state)).toEqual(["Home", "PullRequest"]);
87 expect(state.routes[0]?.key).toBe(previous.routes[0]?.key);
88 expect(state.routes[1]?.key).toBe(previous.routes[2]?.key);
89 expect(names(apply(state, StackActions.pop()))).toEqual(["Home"]);
90 });
91
92 it("replaces peer thread selections without leaving the previous thread underneath", () => {
93 const first = select(router.getInitialState(options), thread);
94 const second = select(first, {
95 name: "Thread",
96 params: { ...thread.params, threadId: "thread-b" },
97 });
98 expect(names(second)).toEqual(["Home", "Thread"]);
99 expect(second.routes[1]?.params).toEqual({ ...thread.params, threadId: "thread-b" });
100 expect(second.routes[1]?.key).not.toBe(first.routes[1]?.key);
101 expect(names(apply(second, StackActions.pop()))).toEqual(["Home"]);
102 });
103
104 it("dismisses nested content and overlays when the sidebar selects a peer", () => {
105 let state = legacyHistory();
106 state = apply(
107 state,
108 StackActions.push("PullRequestFile", { ...pullRequest.params, path: "src/main.ts" }),
109 );
110 state = apply(state, StackActions.push("SettingsSheet"));
111 const selected = select(state, thread);
112 expect(names(selected)).toEqual(["Home", "Thread"]);
113 expect(selected.routes[1]?.params).toEqual(thread.params);
114 });
115
116 it("creates Home beneath cold-linked details and gives direct list escape", () => {
117 const initial = router.getInitialState(options);
118 const cold = apply(initial, CommonActions.reset({ index: 0, routes: [pullRequest] }));
119 expect(names(select(cold, pullRequest))).toEqual(["Home", "PullRequest"]);
120 expect(names(select(cold))).toEqual(["Home"]);
121 });
122
123 it("keeps a request-review flow above the chosen PR instead of an old chat", () => {
124 const state = select(legacyHistory(), pullRequest, pullRequest.params);
125 expect(names(state)).toEqual(["Home", "PullRequest", "PullRequestReviewers"]);
126 const back = apply(state, StackActions.pop());
127 expect(names(back)).toEqual(["Home", "PullRequest"]);
128 expect(back.routes[back.index]?.params).toEqual(pullRequest.params);
129 });
130
131 it("preserves normal nested PR Back behavior", () => {
132 const detail = select(legacyHistory(), pullRequest);
133 const nested = apply(
134 detail,
135 StackActions.push("PullRequestFile", { ...pullRequest.params, path: "file.ts" }),
136 );
137 const back = apply(nested, StackActions.pop());
138 expect(back.routes[back.index]?.key).toBe(detail.routes[detail.index]?.key);
139 expect(names(back)).toEqual(["Home", "PullRequest"]);
140 });
141
142 it.each(["ThreadFiles", "ThreadTerminal", "GitOverview"])(
143 "returns to chat when its sidebar row is selected from %s",
144 (route) => {
145 const detail = select(router.getInitialState(options), thread);
146 const nested = apply(detail, StackActions.push(route, thread.params));
147 const selected = select(nested, thread);
148 expect(names(selected)).toEqual(["Home", "Thread"]);
149 expect(selected.routes[1]?.key).toBe(detail.routes[1]?.key);
150 expect(names(apply(selected, StackActions.pop()))).toEqual(["Home"]);
151 },
152 );
153
154 it.each([
155 { environmentId: "environment-b" },
156 { projectId: "project-b" },
157 { repository: "owner/other" },
158 { host: "git.example.test" },
159 { number: 43 },
160 ])("does not reuse another PR's key when its scope differs: %o", (change) => {
161 const previous = select(router.getInitialState(options), pullRequest);
162 const state = select(previous, {
163 name: "PullRequest",
164 params: { ...pullRequest.params, ...change },
165 });
166 expect(state.routes[1]?.key).not.toBe(previous.routes[1]?.key);
167 expect(state.routes[1]?.params).toEqual({ ...pullRequest.params, ...change });
168 });
169
170 it("does not reuse a thread key from another environment", () => {
171 const previous = select(router.getInitialState(options), thread);
172 const state = select(previous, {
173 name: "Thread",
174 params: { ...thread.params, environmentId: "environment-b" },
175 });
176 expect(state.routes[1]?.key).not.toBe(previous.routes[1]?.key);
177 });
178
179 it("drops stale detail history on a tab switch while retaining Home", () => {
180 const previous = legacyHistory();
181 const state = select(previous);
182 expect(names(state)).toEqual(["Home"]);
183 expect(state.routes[0]?.key).toBe(previous.routes[0]?.key);
184 expect(state.index).toBe(0);
185 });
186});