返回 DeepSeek-Reasonix
shell-support-install.test.tsx
根目录 / desktop / frontend / src / __tests__ / shell-support-install.test.tsx
1 // Run: tsx src/__tests__/shell-support-install.test.tsx
2 //
3 // Sandbox settings shell support contract: Windows exposes native PowerShell
4 // runtimes only, while macOS/Linux expose Bash with copy-only native repair
5 // guidance. Diagnostics stay available without crowding the primary settings.
6
7 import { JSDOM } from "jsdom";
8 import React from "react";
9 import { act } from "react";
10 import { createRoot } from "react-dom/client";
11 import { SettingsPanel } from "../components/SettingsPanel";
12 import { LocaleProvider } from "../lib/i18n";
13 import type { AppBindings } from "../lib/bridge";
14 import type { SettingsView } from "../lib/types";
15 import { baseSettings, flushPromises, installCanvasMock, waitFor } from "../test-support/settingsTestFixtures";
16 import { installDesktopHostStub } from "./desktopHostStub";
17
18 let passed = 0;
19 let failed = 0;
20
21 function ok(value: boolean, label: string) {
22 if (value) {
23 process.stdout.write(` PASS ${label}\n`);
24 passed += 1;
25 } else {
26 process.stdout.write(` FAIL ${label}\n`);
27 failed += 1;
28 }
29 }
30
31 function eq(actual: unknown, expected: unknown, label: string) {
32 const same = actual === expected ||
33 (Array.isArray(actual) && Array.isArray(expected) && JSON.stringify(actual) === JSON.stringify(expected));
34 if (same) {
35 ok(true, label);
36 } else {
37 ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
38 }
39 }
40
41 async function shellOptionValues(rootEl: HTMLElement): Promise<string[]> {
42 const trigger = rootEl.querySelector<HTMLButtonElement>('[aria-haspopup="listbox"]');
43 await act(async () => {
44 trigger?.click();
45 await flushPromises();
46 });
47 const values = Array.from(document.querySelectorAll<HTMLElement>('[role="option"][data-value]'))
48 .map((option) => option.dataset.value ?? "");
49 await act(async () => {
50 trigger?.click();
51 await flushPromises();
52 });
53 return values;
54 }
55
56 console.log("\nshell support guidance");
57
58 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
59 pretendToBeVisual: true,
60 url: "http://localhost/",
61 });
62 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
63 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
64 installCanvasMock(dom.window as unknown as Window);
65 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
66 globalThis.window = dom.window as unknown as Window & typeof globalThis;
67 globalThis.document = dom.window.document;
68 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
69 const copiedCommands: string[] = [];
70 const openedURLs: string[] = [];
71 Object.defineProperty(dom.window.navigator, "clipboard", {
72 configurable: true,
73 value: { writeText: async (value: string) => { copiedCommands.push(value); } },
74 });
75 globalThis.Node = dom.window.Node;
76 globalThis.HTMLElement = dom.window.HTMLElement;
77 globalThis.Event = dom.window.Event;
78 globalThis.CustomEvent = dom.window.CustomEvent;
79 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
80 globalThis.MouseEvent = dom.window.MouseEvent;
81 globalThis.localStorage = dom.window.localStorage;
82 globalThis.sessionStorage = dom.window.sessionStorage;
83 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
84 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
85 window.scrollTo = () => {};
86 window.matchMedia = (() => ({
87 matches: false,
88 media: "",
89 onchange: null,
90 addListener: () => {},
91 removeListener: () => {},
92 addEventListener: () => {},
93 removeEventListener: () => {},
94 dispatchEvent: () => false,
95 })) as typeof window.matchMedia;
96 window.open = ((url?: string | URL) => {
97 openedURLs.push(String(url));
98 return null;
99 }) as typeof window.open;
100 localStorage.clear();
101
102 function windowsSettings(overrides: {
103 shell?: string;
104 reloadRequired?: boolean;
105 manualUrl?: string;
106 }): SettingsView {
107 const settings = baseSettings("standard");
108 settings.sandbox = {
109 ...settings.sandbox,
110 shell: overrides.shell ?? "auto",
111 effectiveShell: "powershell",
112 resolvedShell: overrides.reloadRequired ? "pwsh" : "powershell",
113 shellReloadRequired: overrides.reloadRequired ?? false,
114 shellCapabilities: [
115 // Legacy data may still be replayed from an older backend. The current UI
116 // must filter it rather than presenting Bash as a Windows Agent runtime.
117 { id: "git-bash", variant: "git-for-windows", available: true, path: "C:\\Program Files\\Git\\bin\\bash.exe", source: "standard-path" },
118 { id: "powershell", available: true, path: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", source: "standard-path" },
119 { id: "pwsh", available: true, path: "C:\\Program Files\\PowerShell\\7\\pwsh.exe", source: "standard-path" },
120 ],
121 gitCapability: { id: "git", available: true, path: "C:\\Program Files\\Git\\cmd\\git.exe", source: "standard-path" },
122 shellInstallAction: { id: "git-for-windows", mode: "manual", available: false, manualUrl: overrides.manualUrl ?? "https://git-scm.com/download/win" },
123 };
124 return settings;
125 }
126
127 // Scenario 1: Windows presents only the two native PowerShell runtimes. Legacy
128 // Bash capabilities and install actions never leak into the settings surface.
129 {
130 const rootEl = document.createElement("div");
131 document.body.appendChild(rootEl);
132 const root = createRoot(rootEl);
133 let installCalls = 0;
134 let cancelCalls = 0;
135 let reloadCalls = 0;
136 let settingsCalls = 0;
137 const shellPreferenceCalls: string[] = [];
138 const desktopStub = installDesktopHostStub(({
139 main: {
140 App: {
141 Settings: async () => {
142 settingsCalls += 1;
143 return windowsSettings({ shell: "bash", reloadRequired: true, manualUrl: "https://evil.example/?next=https://git-scm.com/download/win" });
144 },
145 SetShellPreference: async (value: string) => { shellPreferenceCalls.push(value); },
146 InstallShellSupport: async () => {
147 installCalls += 1;
148 return { status: "manual_required", manualUrl: "https://git-scm.com/download/win" };
149 },
150 CancelShellInstall: async () => { cancelCalls += 1; },
151 ReloadSettings: async () => { reloadCalls += 1; },
152 } as Partial<AppBindings> as AppBindings,
153 },
154 }).main.App, { externalOpens: openedURLs });
155 await act(async () => {
156 root.render(
157 <LocaleProvider>
158 <SettingsPanel initialTab="sandbox" desktopPlatform="windows" onClose={() => {}} onChanged={() => {}} />
159 </LocaleProvider>,
160 );
161 await flushPromises();
162 });
163 await waitFor("Windows PowerShell runtime", () => rootEl.textContent?.includes("PowerShell runtime") === true);
164 const optionValues = await shellOptionValues(rootEl);
165 eq(optionValues, ["auto", "pwsh", "powershell"], "Windows selector contains only native PowerShell runtimes");
166 const shellTrigger = rootEl.querySelector<HTMLButtonElement>('[aria-haspopup="listbox"]');
167 await act(async () => {
168 shellTrigger?.click();
169 await flushPromises();
170 document.querySelector<HTMLElement>('[role="option"][data-value="auto"]')?.click();
171 await flushPromises();
172 });
173 eq(shellPreferenceCalls, ["auto"], "selecting the visible auto option migrates a retained legacy Bash preference");
174 ok(rootEl.textContent?.includes("Git Bash") !== true, "Windows hides replayed Git Bash capability data");
175 ok(rootEl.textContent?.includes("Git for Windows") !== true, "Windows hides legacy Git for Windows repair actions");
176 ok(rootEl.textContent?.includes("C:\\Windows\\System32\\WindowsPowerShell") === true,
177 "current Windows runtime includes its resolved executable path");
178 ok(rootEl.textContent?.includes("Runtime details") === true, "diagnostics are grouped under runtime details");
179 eq(openedURLs.length, 0, "rendering Windows settings opens no external installer page");
180 eq(installCalls, 0, "rendering Windows repair never calls InstallShellSupport");
181 eq(cancelCalls, 0, "manual-only Windows repair never calls CancelShellInstall");
182
183 const repairReloadButton = Array.from(rootEl.querySelectorAll("button")).find((button) => button.textContent?.includes("Reload current session"));
184 ok(Boolean(repairReloadButton), "Windows shows reload only when the resolved runtime changed");
185 await act(async () => {
186 repairReloadButton!.click();
187 await flushPromises();
188 });
189 eq(reloadCalls, 1, "Windows reloads only after the user requests it");
190 eq(settingsCalls, 3, "preference migration and reload each refresh the Settings snapshot once");
191 eq(installCalls, 0, "reload never calls the legacy install binding");
192 await act(async () => { root.unmount(); });
193 }
194
195 // Scenario 2: Linux reports bash/zsh/sh, offers an allowlisted distro command
196 // for copying, and only re-detects after the user explicitly requests it.
197 {
198 const rootEl = document.createElement("div");
199 document.body.appendChild(rootEl);
200 const root = createRoot(rootEl);
201 const linuxSettings = baseSettings("standard");
202 linuxSettings.sandbox = {
203 ...linuxSettings.sandbox,
204 shellCapabilities: [
205 { id: "bash", variant: "system", available: false, reason: "not-found" },
206 { id: "zsh", variant: "system", available: false, reason: "not-found" },
207 { id: "sh", variant: "system", available: true, path: "/bin/sh", source: "standard-path" },
208 ],
209 gitCapability: { id: "git", available: true, path: "/usr/bin/git", source: "path" },
210 shellInstallAction: null,
211 shellRepairGuidance: { manager: "apt", command: "apt-get install bash" },
212 };
213 let reloadCalls = 0;
214 const desktopStub = installDesktopHostStub(({
215 main: {
216 App: {
217 Settings: async () => linuxSettings,
218 SetShellPreference: async () => {},
219 InstallShellSupport: async () => ({ status: "unsupported_platform" }),
220 CancelShellInstall: async () => {},
221 ReloadSettings: async () => { reloadCalls += 1; },
222 } as Partial<AppBindings> as AppBindings,
223 },
224 }).main.App, { externalOpens: openedURLs });
225 await act(async () => {
226 root.render(
227 <LocaleProvider>
228 <SettingsPanel initialTab="sandbox" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
229 </LocaleProvider>,
230 );
231 await flushPromises();
232 });
233 await waitFor("Linux detection", () => rootEl.textContent?.includes("Bash") === true);
234 eq(await shellOptionValues(rootEl), ["auto", "bash"],
235 "Linux selector contains no PowerShell runtimes");
236 ok(!Array.from(rootEl.querySelectorAll("button")).some((button) => button.textContent?.includes("Install Git for Windows")),
237 "Linux never renders a Windows install entry");
238 ok(rootEl.textContent?.includes("zsh") === true && rootEl.textContent?.includes("POSIX sh") === true,
239 "Linux detection reports zsh and POSIX sh alongside Bash");
240 ok(rootEl.textContent?.includes("apt-get install bash") === true, "Linux missing Bash shows the distro repair command");
241 ok(!rootEl.textContent?.includes("sudo apt-get") && !rootEl.textContent?.includes("sudo"),
242 "Linux repair guidance never prescribes sudo");
243 const copyButton = Array.from(rootEl.querySelectorAll("button")).find((button) => button.textContent?.includes("Copy command"));
244 ok(Boolean(copyButton), "Linux repair command is copyable");
245 await act(async () => {
246 copyButton!.click();
247 await flushPromises();
248 });
249 eq(copiedCommands.at(-1), "apt-get install bash", "copy action writes the exact allowlisted command");
250 const repairReloadButton = Array.from(rootEl.querySelectorAll("button")).find((button) => button.textContent?.includes("Re-detect and reload session"));
251 ok(Boolean(repairReloadButton), "Linux manual repair offers explicit re-detection");
252 await act(async () => {
253 repairReloadButton!.click();
254 await flushPromises();
255 });
256 eq(reloadCalls, 1, "Linux repair reload remains an explicit user action");
257 await act(async () => { root.unmount(); });
258 }
259
260 // Scenario 3: macOS falls back to zsh when Bash is missing, while Git remains
261 // a separate capability with its own copy-only Homebrew repair command.
262 {
263 const rootEl = document.createElement("div");
264 document.body.appendChild(rootEl);
265 const root = createRoot(rootEl);
266 const macSettings = baseSettings("standard");
267 macSettings.sandbox = {
268 ...macSettings.sandbox,
269 effectiveShell: "zsh",
270 resolvedShell: "zsh",
271 shellCapabilities: [
272 { id: "bash", variant: "system", available: false, reason: "not-found" },
273 { id: "zsh", variant: "system", available: true, path: "/bin/zsh", source: "standard-path" },
274 { id: "sh", variant: "system", available: true, path: "/bin/sh", source: "standard-path" },
275 ],
276 gitCapability: { id: "git", available: false, reason: "not-found" },
277 shellInstallAction: null,
278 shellRepairGuidance: null,
279 gitRepairGuidance: { manager: "homebrew", command: "brew install git" },
280 };
281 const desktopStub = installDesktopHostStub(({
282 main: {
283 App: {
284 Settings: async () => macSettings,
285 SetShellPreference: async () => {},
286 InstallShellSupport: async () => ({ status: "unsupported_platform" }),
287 CancelShellInstall: async () => {},
288 ReloadSettings: async () => {},
289 } as Partial<AppBindings> as AppBindings,
290 },
291 }).main.App, { externalOpens: openedURLs });
292 await act(async () => {
293 root.render(
294 <LocaleProvider>
295 <SettingsPanel initialTab="sandbox" desktopPlatform="darwin" onClose={() => {}} onChanged={() => {}} />
296 </LocaleProvider>,
297 );
298 await flushPromises();
299 });
300 await waitFor("macOS shell inventory", () => rootEl.textContent?.includes("POSIX sh") === true);
301 eq(await shellOptionValues(rootEl), ["auto", "bash"],
302 "macOS selector contains no PowerShell runtimes");
303 ok(rootEl.textContent?.includes("zsh") === true && rootEl.textContent?.includes("POSIX sh") === true,
304 "macOS detection reports native zsh and POSIX sh");
305 ok(!rootEl.textContent?.includes("brew install bash") && !rootEl.textContent?.includes("Bash is not detected"),
306 "macOS native zsh fallback does not request a Bash install");
307 ok(rootEl.textContent?.includes("Git") === true && rootEl.textContent?.includes("brew install git") === true,
308 "macOS missing Git shows an independent Homebrew Git repair command");
309 ok(rootEl.textContent?.includes("Shell after reload") !== true,
310 "unchanged runtime does not render a duplicate after-reload row");
311 const gitCopyButton = Array.from(rootEl.querySelectorAll("button")).find((button) => button.textContent?.includes("Copy command"));
312 await act(async () => {
313 gitCopyButton!.click();
314 await flushPromises();
315 });
316 eq(copiedCommands.at(-1), "brew install git", "macOS Git repair copies brew install git only");
317 ok(!Array.from(rootEl.querySelectorAll("button")).some((button) => button.textContent?.includes("Install Git for Windows")),
318 "macOS never renders the Windows install entry");
319 await act(async () => { root.unmount(); });
320 }
321
322 if (failed > 0) {
323 console.error(`\n${failed} failed, ${passed} passed`);
324 process.exit(1);
325 }
326 console.log(`\n${passed} passed, 0 failed`);
327
327 lines Plain Text