1// @vitest-environment jsdom
2import { act, useState, type ReactNode } from "react";
3import { createRoot, type Root } from "react-dom/client";
4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
5import type { SettingsScopeSearch } from "./settingsScope";
6
7interface ScopeState {
8 readonly search: SettingsScopeSearch;
9 readonly environmentId: string | null;
10}
11
12const state = vi.hoisted(() => ({
13 pathname: "/settings/diagnostics",
14 scope: { search: {}, environmentId: null } as ScopeState,
15}));
16
17vi.mock("@tanstack/react-router", async (importOriginal) => ({
18 ...(await importOriginal<typeof import("@tanstack/react-router")>()),
19 createFileRoute: () => (options: { component: () => ReactNode }) => ({
20 options,
21 useSearch: () => state.scope.search,
22 useNavigate: () => vi.fn(),
23 }),
24 useLocation: (options?: { select: (location: unknown) => unknown }) => {
25 const location = { pathname: state.pathname, hash: "", state: {} };
26 return options ? options.select(location) : location;
27 },
28 Outlet: () => <StatefulContent />,
29 redirect: vi.fn(),
30}));
31vi.mock("./SettingsScopeContext", async () => {
32 const { createContext, useContext } = await import("react");
33 const Scope = createContext<ScopeState>(state.scope);
34 return {
35 SettingsScopeProvider: ({ children }: { children: ReactNode }) => (
36 <Scope value={state.scope}>{children}</Scope>
37 ),
38 useSettingsScope: () => {
39 const { search, environmentId } = useContext(Scope);
40 return {
41 search,
42 scope: { kind: "all" },
43 environment: environmentId ? { environmentId } : null,
44 connectedEnvironments: environmentId ? [{}] : [],
45 };
46 },
47 };
48});
49vi.mock("../../state/environments", () => ({ useEnvironments: () => ({ environments: [] }) }));
50vi.mock("../ui/sidebar", () => ({
51 SidebarInset: ({ children }: { children: ReactNode }) => <div>{children}</div>,
52}));
53vi.mock("../WorkspacePageHeader", () => ({
54 WorkspacePageHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
55}));
56vi.mock("./settingsLayout", () => ({
57 SettingsPageContainer: ({ children }: { children: ReactNode }) => <div>{children}</div>,
58}));
59vi.mock("./SettingsBreadcrumb", () => ({ SettingsBreadcrumb: () => null }));
60vi.mock("../sidebar/mainAppLocation", () => ({ useNavigateToMainApp: () => vi.fn() }));
61vi.mock("../../hooks/useNavigateBack", () => ({ useEscapeToGoBack: () => undefined }));
62vi.mock("./SettingsPanels", () => ({ useSettingsRestore: () => ({ changedSettingLabels: [] }) }));
63
64import { Route } from "../../routes/settings";
65
66function StatefulContent() {
67 const [value, setValue] = useState(0);
68 return (
69 <button aria-label="Retained value" onClick={() => setValue(value + 1)}>
70 {value}
71 </button>
72 );
73}
74
75let root: Root;
76let container: HTMLDivElement;
77const Layout = Route.options.component!;
78await Layout.preload?.();
79
80async function render(scope: ScopeState) {
81 state.scope = scope;
82 await act(async () => root.render(<Layout />));
83}
84function retainedValue() {
85 return container.querySelector('button[aria-label="Retained value"]')?.textContent ?? null;
86}
87
88beforeEach(() => {
89 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
90 container = document.createElement("div");
91 document.body.append(container);
92 root = createRoot(container);
93});
94afterEach(async () => {
95 await act(async () => root.unmount());
96 container.remove();
97 vi.unstubAllGlobals();
98});
99
100const first = { search: { machine: "first", project: "one" }, environmentId: "first" };
101const otherProject = { search: { machine: "first", project: "two" }, environmentId: "first" };
102const otherMachine = { search: { machine: "second", project: "one" }, environmentId: "second" };
103const implicitFirst = { search: { project: "one" }, environmentId: "first" };
104const implicitSecond = { search: { project: "one" }, environmentId: "second" };
105
106const disconnected = { search: first.search, environmentId: null };
107
108it.each([
109 ["/settings/diagnostics", "another project", "1", first, otherProject],
110 ["/settings/providers", "another project", "1", first, otherProject],
111 ["/settings/diagnostics", "another environment", "0", first, otherMachine],
112 ["/settings/providers", "another environment", "0", first, otherMachine],
113 ["/settings/diagnostics", "an implicit environment change", "0", implicitFirst, implicitSecond],
114 ["/settings/providers", "an implicit environment change", "0", implicitFirst, implicitSecond],
115 ["/settings/diagnostics", "a disconnect", "0", first, disconnected],
116 ["/settings/keybindings", "another project", "0", first, otherProject],
117])("%s after %s shows %s", async (pathname, _change, expected, before, after) => {
118 state.pathname = pathname;
119 await render(before);
120 await act(async () =>
121 container.querySelector<HTMLButtonElement>('button[aria-label="Retained value"]')?.click(),
122 );
123 expect(retainedValue()).toBe("1");
124
125 await render(after);
126
127 expect(retainedValue()).toBe(expected);
128});