1import * as NodeModule from "node:module";
2import type { NavigationState, PartialState } from "@react-navigation/native";
3import { expect, it, vi } from "vite-plus/test";
4
5const requireFromNative = NodeModule.createRequire(
6 NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"),
7);
8const requireFromCore = NodeModule.createRequire(
9 requireFromNative.resolve("@react-navigation/core/package.json"),
10);
11const { getActionFromState, getStateFromPath } = requireFromNative("@react-navigation/core") as {
12 readonly getActionFromState: typeof import("@react-navigation/native").getActionFromState;
13 readonly getStateFromPath: typeof import("@react-navigation/native").getStateFromPath;
14};
15const { StackRouter } = requireFromCore("@react-navigation/routers") as {
16 readonly StackRouter: typeof import("@react-navigation/native").StackRouter;
17};
18
19const harness = vi.hoisted(() => ({
20 pair: vi.fn(),
21 goBack: vi.fn(),
22 dispatch: vi.fn(),
23 columns: false,
24 // The root stack the form sits on when pairing succeeds.
25 rootState: {
26 key: "root",
27 index: 2,
28 routes: [
29 { key: "home", name: "Home" },
30 { key: "settings", name: "Settings" },
31 { key: "new", name: "SettingsEnvironmentNew" },
32 ],
33 },
34 // Navigation that is not ready yet holds callbacks here until the test flushes them.
35 deferred: null as Array<() => void> | null,
36}));
37
38vi.mock("@react-navigation/native", () => ({
39 StackActions: { replace: vi.fn() },
40 CommonActions: {
41 replaceParams: (params: object) => ({ type: "REPLACE_PARAMS", payload: { params } }),
42 },
43}));
44vi.mock("../../state/use-remote-environment-registry", () => ({
45 pairEnvironment: harness.pair,
46}));
47vi.mock("../layout/workspace/workspace-selection-store", () => ({
48 workspaceHasColumns: () => harness.columns,
49}));
50vi.mock("../../lib/rootNavigation", () => ({
51 rootNavigationRef: {
52 getCurrentRoute: () => harness.rootState.routes[harness.rootState.index],
53 getRootState: () => harness.rootState,
54 getState: () => harness.rootState,
55 getParent: () => undefined,
56 canGoBack: () => true,
57 goBack: harness.goBack,
58 dispatch: harness.dispatch,
59 },
60 whenRootNavigationReady: (navigate: () => void) => {
61 if (harness.deferred) harness.deferred.push(navigate);
62 else navigate();
63 },
64}));
65
66import { appAtomRegistry } from "../../state/atom-registry";
67import {
68 autoConnectReport,
69 devAutoConnectAttemptAtom,
70 pairingFormLinkAction,
71 startDevAutoConnect,
72} from "./devAutoConnect";
73
74const PAIRING_URL = "http://192.168.1.10:3773/pair#token=abc";
75
76function settingsLink(params: object): PartialState<NavigationState> {
77 return {
78 routes: [
79 { name: "Home" },
80 { name: "Settings", state: { routes: [{ name: "SettingsEnvironmentNew", params }] } },
81 ],
82 };
83}
84
85it("pairs from a nested settings link once, reports failure, and closes the form on success", async () => {
86 vi.stubGlobal("__DEV__", true);
87 const failed = Promise.withResolvers<{ ok: boolean; error: string | null }>();
88 harness.pair.mockReturnValueOnce(failed.promise);
89
90 const state = settingsLink({ pairingUrl: ` ${PAIRING_URL} `, autoConnect: "1" });
91 expect(startDevAutoConnect(state)).toBe(state);
92 expect(harness.pair).toHaveBeenCalledExactlyOnceWith(PAIRING_URL);
93 expect(appAtomRegistry.get(devAutoConnectAttemptAtom)).toEqual({
94 pairingUrl: PAIRING_URL,
95 status: "connecting",
96 });
97
98 failed.resolve({ ok: false, error: "Pairing code expired." });
99 await failed.promise;
100 await Promise.resolve();
101 expect(appAtomRegistry.get(devAutoConnectAttemptAtom)).toEqual({
102 pairingUrl: PAIRING_URL,
103 status: "failed",
104 error: "Pairing code expired.",
105 });
106 expect(harness.goBack).not.toHaveBeenCalled();
107
108 const succeeded = Promise.withResolvers<{ ok: boolean; error: string | null }>();
109 harness.pair.mockReturnValueOnce(succeeded.promise);
110 startDevAutoConnect(settingsLink({ pairingUrl: PAIRING_URL, autoConnect: "true" }));
111 succeeded.resolve({ ok: true, error: null });
112 await succeeded.promise;
113 await Promise.resolve();
114 expect(appAtomRegistry.get(devAutoConnectAttemptAtom)).toBeNull();
115 expect(harness.goBack).toHaveBeenCalledOnce();
116});
117
118it("only prefills without the flag, and ignores the flag in production builds", () => {
119 harness.pair.mockClear();
120 vi.stubGlobal("__DEV__", true);
121 startDevAutoConnect(settingsLink({ pairingUrl: PAIRING_URL }));
122 vi.stubGlobal("__DEV__", false);
123 startDevAutoConnect(settingsLink({ pairingUrl: PAIRING_URL, autoConnect: "1" }));
124 expect(harness.pair).not.toHaveBeenCalled();
125});
126
127it("forgets a failed auto-connect once the form is opened again without the flag", async () => {
128 vi.stubGlobal("__DEV__", true);
129 const failed = Promise.withResolvers<{ ok: boolean; error: string | null }>();
130 harness.pair.mockReturnValueOnce(failed.promise);
131 startDevAutoConnect(settingsLink({ pairingUrl: PAIRING_URL, autoConnect: "1" }));
132 failed.resolve({ ok: false, error: "Pairing code expired." });
133 await failed.promise;
134 await Promise.resolve();
135 expect(appAtomRegistry.get(devAutoConnectAttemptAtom)).toMatchObject({ status: "failed" });
136
137 startDevAutoConnect(settingsLink({ pairingUrl: PAIRING_URL }));
138 expect(appAtomRegistry.get(devAutoConnectAttemptAtom)).toBeNull();
139});
140
141it("drops the result of a pairing that a later link superseded", async () => {
142 vi.stubGlobal("__DEV__", true);
143 const stale = Promise.withResolvers<{ ok: boolean; error: string | null }>();
144 harness.pair.mockReturnValueOnce(stale.promise);
145 startDevAutoConnect(settingsLink({ pairingUrl: PAIRING_URL, autoConnect: "1" }));
146 startDevAutoConnect(settingsLink({ pairingUrl: PAIRING_URL }));
147
148 stale.resolve({ ok: false, error: "Pairing code expired." });
149 await stale.promise;
150 await Promise.resolve();
151 expect(appAtomRegistry.get(devAutoConnectAttemptAtom)).toBeNull();
152});
153
154it("keeps Connect busy while the auto-connect runs, even after the user edits the form", () => {
155 const connecting = { pairingUrl: PAIRING_URL, status: "connecting" } as const;
156 expect(autoConnectReport(connecting, PAIRING_URL, true)).toEqual({
157 connecting: true,
158 error: null,
159 });
160 const failed = { pairingUrl: PAIRING_URL, status: "failed", error: "Expired." } as const;
161 expect(autoConnectReport(failed, PAIRING_URL, false)).toEqual({
162 connecting: false,
163 error: "Expired.",
164 });
165 expect(autoConnectReport(failed, PAIRING_URL, true).error).toBeNull();
166 expect(autoConnectReport(connecting, "http://other/pair#token=x", false).connecting).toBe(false);
167});
168
169async function pairSuccessfully(link: PartialState<NavigationState>) {
170 const succeeded = Promise.withResolvers<{ ok: boolean; error: string | null }>();
171 harness.pair.mockReturnValueOnce(succeeded.promise);
172 startDevAutoConnect(link);
173 succeeded.resolve({ ok: true, error: null });
174 await succeeded.promise;
175 await Promise.resolve();
176}
177
178it("on columns, a successful settings link shows Environments instead of an empty detail", async () => {
179 vi.stubGlobal("__DEV__", true);
180 harness.goBack.mockClear();
181 harness.dispatch.mockClear();
182 harness.columns = true;
183 await pairSuccessfully(settingsLink({ pairingUrl: PAIRING_URL, autoConnect: "1" }));
184 harness.columns = false;
185
186 expect(harness.goBack).not.toHaveBeenCalled();
187 expect(harness.dispatch).toHaveBeenCalledExactlyOnceWith({
188 type: "RESET",
189 target: "root",
190 payload: expect.objectContaining({
191 index: 2,
192 routes: [
193 { key: "home", name: "Home" },
194 { key: "settings", name: "Settings" },
195 { name: "SettingsEnvironments", params: undefined },
196 ],
197 }),
198 });
199});
200
201it("a success that waited for navigation leaves a newer link's form open", async () => {
202 vi.stubGlobal("__DEV__", true);
203 harness.goBack.mockClear();
204 harness.dispatch.mockClear();
205 harness.deferred = [];
206 await pairSuccessfully(settingsLink({ pairingUrl: PAIRING_URL, autoConnect: "1" }));
207 startDevAutoConnect(settingsLink({ pairingUrl: "http://192.168.1.11:3773/pair#token=def" }));
208
209 const deferred = harness.deferred;
210 harness.deferred = null;
211 for (const navigate of deferred) navigate();
212 expect(harness.goBack).not.toHaveBeenCalled();
213 expect(harness.dispatch).not.toHaveBeenCalled();
214});
215
216it("a pairing link fills the pairing form already on screen instead of stacking a second one", () => {
217 const linking = {
218 initialRouteName: "Home",
219 screens: {
220 Home: "",
221 Settings: "settings",
222 SettingsEnvironmentNew: "settings/environment-new",
223 ConnectionsNew: "connections/new",
224 },
225 };
226 const router = StackRouter({});
227 const options = {
228 routeNames: Object.keys(linking.screens),
229 routeParamList: {},
230 routeGetIdList: {},
231 };
232 // Settings > Environments > Add environment, as the user opened it.
233 const open = router.getRehydratedState(
234 {
235 index: 2,
236 routes: [{ name: "Home" }, { name: "Settings" }, { name: "SettingsEnvironmentNew" }],
237 },
238 options,
239 );
240 const link = getStateFromPath(
241 `connections/new?pairingUrl=${encodeURIComponent(PAIRING_URL)}`,
242 linking,
243 )!;
244 const action = pairingFormLinkAction(link, open) ?? getActionFromState(link, linking)!;
245 const next = router.getStateForAction(open, { target: open.key, ...action }, options);
246
247 expect(next?.routes.map((route) => route.name)).toEqual([
248 "Home",
249 "Settings",
250 "SettingsEnvironmentNew",
251 ]);
252 expect(next?.routes.at(-1)).toMatchObject({
253 key: open.routes[2]!.key,
254 params: { pairingUrl: PAIRING_URL },
255 });
256});