返回 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 Git Bash and native
4 // PowerShell runtimes, while macOS/Linux expose Bash with copy-only native repair
5 // guidance. The 1.38.10 layout keeps diagnostics and write roots inline.
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 gitBashAvailable?: boolean;
107 }): SettingsView {
108 const settings = baseSettings("standard");
109 settings.sandbox = {
110 ...settings.sandbox,
111 shell: overrides.shell ?? "auto",
112 effectiveShell: "powershell",
113 resolvedShell: overrides.shell === "bash" ? "git-bash" : overrides.reloadRequired ? "pwsh" : "powershell",
114 shellReloadRequired: overrides.reloadRequired ?? false,
115 shellCapabilities: [
116 { id: "git-bash", variant: "git-for-windows", available: overrides.gitBashAvailable ?? true, path: overrides.gitBashAvailable === false ? undefined : "C:\\Program Files\\Git\\bin\\bash.exe", source: "standard-path" },
117 { id: "powershell", available: true, path: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", source: "standard-path" },
118 { id: "pwsh", available: true, path: "C:\\Program Files\\PowerShell\\7\\pwsh.exe", source: "standard-path" },
119 ],
120 gitCapability: { id: "git", available: true, path: "C:\\Program Files\\Git\\cmd\\git.exe", source: "standard-path" },
121 shellInstallAction: { id: "git-for-windows", mode: "manual", available: false, manualUrl: overrides.manualUrl ?? "https://git-scm.com/download/win" },
122 };
123 return settings;
124 }
125
126 // Scenario 1: Windows detects Git Bash and can explicitly select its existing
127 // persisted "bash" preference without changing the PowerShell auto default.
128 {
129 const rootEl = document.createElement("div");
130 document.body.appendChild(rootEl);
131 const root = createRoot(rootEl);
132 let installCalls = 0;
133 let cancelCalls = 0;
134 let reloadCalls = 0;
135 let settingsCalls = 0;
136 const shellPreferenceCalls: string[] = [];
137 let selectedShell = "auto";
138 const desktopStub = installDesktopHostStub(({
139 main: {
140 App: {
141 Settings: async () => {
142 settingsCalls += 1;
143 return windowsSettings({ shell: selectedShell, reloadRequired: true });
144 },
145 SetShellPreference: async (value: string) => { shellPreferenceCalls.push(value); selectedShell = 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 Shell interpreter", () => rootEl.textContent?.includes("Shell interpreter") === true);
164 const optionValues = await shellOptionValues(rootEl);
165 eq(optionValues, ["auto", "bash", "pwsh", "powershell"], "Windows selector restores Git Bash alongside PowerShell");
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="bash"]')?.click();
171 await flushPromises();
172 });
173 eq(shellPreferenceCalls, ["bash"], "Git Bash selection persists the backward-compatible bash preference");
174 eq(rootEl.querySelector('[aria-haspopup="listbox"]')?.textContent, "Git Bash", "saved Bash preference remains selected after settings refresh");
175 ok(rootEl.textContent?.includes("C:\\Program Files\\Git\\bin\\bash.exe") === true, "Windows displays the detected Git Bash executable");
176 ok(rootEl.textContent?.includes("Git for Windows") !== true, "installed Git Bash needs no repair card");
177 ok(rootEl.textContent?.includes("C:\\Windows\\System32\\WindowsPowerShell") === true,
178 "Windows shell detection includes its resolved executable path");
179 ok(rootEl.querySelector(".runtime-details") === null, "diagnostics remain inline in the restored layout");
180 const sandboxSection = rootEl.querySelector(".settings-page--sandbox .settings-section");
181 const headerApplyButton = sandboxSection?.querySelector<HTMLButtonElement>(".settings-section__actions button");
182 eq(headerApplyButton?.textContent, "Reload configuration", "manual configuration action stays in the section header");
183 ok(sandboxSection!.textContent!.indexOf("Runtime environment") < sandboxSection!.textContent!.indexOf("Workspace root")
184 && sandboxSection!.textContent!.indexOf("Currently allowed directories") > sandboxSection!.textContent!.indexOf("Workspace root"),
185 "runtime diagnostics precede the grouped file write settings");
186 ok(!sandboxSection!.querySelector('input[type="checkbox"]'), "Windows network access is a status, not a disabled toggle");
187 ok(sandboxSection!.textContent!.includes("shell commands are not restricted"), "Windows file-tool scope is explicit");
188 await act(async () => { rootEl.querySelector<HTMLButtonElement>('[aria-label="Copy Git Bash path"]')!.click(); await flushPromises(); });
189 eq(copiedCommands.at(-1), "C:\\Program Files\\Git\\bin\\bash.exe", "detected executable paths can be copied in full");
190 eq(openedURLs.length, 0, "rendering Windows settings opens no external installer page");
191 eq(installCalls, 0, "rendering Windows repair never calls InstallShellSupport");
192 eq(cancelCalls, 0, "manual-only Windows repair never calls CancelShellInstall");
193
194 const repairReloadButton = Array.from(rootEl.querySelectorAll("button")).find((button) => button.textContent?.includes("Reload current session"));
195 ok(Boolean(repairReloadButton), "Windows shows reload only when the resolved runtime changed");
196 await act(async () => {
197 repairReloadButton!.click();
198 await flushPromises();
199 });
200 eq(reloadCalls, 1, "Windows reloads only after the user requests it");
201 eq(settingsCalls, 3, "preference selection and reload each refresh the Settings snapshot once");
202 await act(async () => {
203 headerApplyButton!.click();
204 await flushPromises();
205 });
206 eq(reloadCalls, 2, "restored header action applies manual configuration changes");
207 eq(installCalls, 0, "reload never calls the legacy install binding");
208 await act(async () => { root.unmount(); });
209 }
210
211 // Missing Git Bash has a manual, fixed official download target, including
212 // when an old backend supplies an untrusted URL in its compatibility fields.
213 {
214 const rootEl = document.createElement("div");
215 document.body.appendChild(rootEl);
216 const root = createRoot(rootEl);
217 let reloadCalls = 0;
218 let installCalls = 0;
219 installDesktopHostStub({
220 Settings: async () => windowsSettings({ gitBashAvailable: false, manualUrl: "https://evil.example/installer" }),
221 ReloadSettings: async () => { reloadCalls += 1; },
222 InstallShellSupport: async () => { installCalls += 1; return { status: "manual_required" }; },
223 } as Partial<AppBindings> as AppBindings, { externalOpens: openedURLs });
224 await act(async () => {
225 root.render(<LocaleProvider><SettingsPanel initialTab="sandbox" desktopPlatform="windows" onClose={() => {}} onChanged={() => {}} /></LocaleProvider>);
226 await flushPromises();
227 });
228 await waitFor("manual Git Bash repair", () => rootEl.textContent?.includes("Git Bash was not detected") === true);
229 const download = Array.from(rootEl.querySelectorAll("button")).find(button => button.textContent?.includes("Download from git-scm.com"));
230 const reload = Array.from(rootEl.querySelectorAll("button")).find(button => button.textContent?.includes("Re-detect and reload session"));
231 ok(Boolean(download && reload), "missing Git Bash offers manual download and re-detection");
232 await act(async () => {
233 download!.click();
234 reload!.click();
235 await flushPromises();
236 });
237 eq(openedURLs.at(-1), "https://git-scm.com/download/win", "manual repair only opens the fixed official download page");
238 eq(reloadCalls, 1, "manual repair re-detects and reloads once on request");
239 eq(installCalls, 0, "manual repair never launches an installer");
240 await act(async () => { root.unmount(); });
241 }
242
243 // Scenario 2: Linux reports bash/zsh/sh, offers an allowlisted distro command
244 // for copying, and only re-detects after the user explicitly requests it.
245 {
246 const rootEl = document.createElement("div");
247 document.body.appendChild(rootEl);
248 const root = createRoot(rootEl);
249 const linuxSettings = baseSettings("standard");
250 linuxSettings.sandbox = {
251 ...linuxSettings.sandbox,
252 shellCapabilities: [
253 { id: "bash", variant: "system", available: false, reason: "not-found" },
254 { id: "zsh", variant: "system", available: false, reason: "not-found" },
255 { id: "sh", variant: "system", available: true, path: "/bin/sh", source: "standard-path" },
256 ],
257 gitCapability: { id: "git", available: true, path: "/usr/bin/git", source: "path" },
258 shellInstallAction: null,
259 shellRepairGuidance: { manager: "apt", command: "apt-get install bash" },
260 };
261 let reloadCalls = 0;
262 const desktopStub = installDesktopHostStub(({
263 main: {
264 App: {
265 Settings: async () => linuxSettings,
266 SetShellPreference: async () => {},
267 InstallShellSupport: async () => ({ status: "unsupported_platform" }),
268 CancelShellInstall: async () => {},
269 ReloadSettings: async () => { reloadCalls += 1; },
270 } as Partial<AppBindings> as AppBindings,
271 },
272 }).main.App, { externalOpens: openedURLs });
273 await act(async () => {
274 root.render(
275 <LocaleProvider>
276 <SettingsPanel initialTab="sandbox" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
277 </LocaleProvider>,
278 );
279 await flushPromises();
280 });
281 await waitFor("Linux detection", () => rootEl.textContent?.includes("Bash") === true);
282 eq(await shellOptionValues(rootEl), ["auto", "bash"],
283 "Linux selector contains no PowerShell runtimes");
284 ok(!Array.from(rootEl.querySelectorAll("button")).some((button) => button.textContent?.includes("Install Git for Windows")),
285 "Linux never renders a Windows install entry");
286 ok(rootEl.textContent?.includes("zsh") === true && rootEl.textContent?.includes("POSIX sh") === true,
287 "Linux detection reports zsh and POSIX sh alongside Bash");
288 ok(rootEl.textContent?.includes("apt-get install bash") === true, "Linux missing Bash shows the distro repair command");
289 ok(!rootEl.textContent?.includes("sudo apt-get") && !rootEl.textContent?.includes("sudo"),
290 "Linux repair guidance never prescribes sudo");
291 const copyButton = Array.from(rootEl.querySelectorAll("button")).find((button) => button.textContent?.includes("Copy command"));
292 ok(Boolean(copyButton), "Linux repair command is copyable");
293 await act(async () => {
294 copyButton!.click();
295 await flushPromises();
296 });
297 eq(copiedCommands.at(-1), "apt-get install bash", "copy action writes the exact allowlisted command");
298 const repairReloadButton = Array.from(rootEl.querySelectorAll("button")).find((button) => button.textContent?.includes("Re-detect and reload session"));
299 ok(Boolean(repairReloadButton), "Linux manual repair offers explicit re-detection");
300 await act(async () => {
301 repairReloadButton!.click();
302 await flushPromises();
303 });
304 eq(reloadCalls, 1, "Linux repair reload remains an explicit user action");
305 await act(async () => { root.unmount(); });
306 }
307
308 // Scenario 3: macOS falls back to zsh when Bash is missing, while Git remains
309 // a separate capability with its own copy-only Homebrew repair command.
310 {
311 const rootEl = document.createElement("div");
312 document.body.appendChild(rootEl);
313 const root = createRoot(rootEl);
314 const macSettings = baseSettings("standard");
315 macSettings.sandbox = {
316 ...macSettings.sandbox,
317 effectiveShell: "zsh",
318 resolvedShell: "zsh",
319 shellCapabilities: [
320 { id: "bash", variant: "system", available: false, reason: "not-found" },
321 { id: "zsh", variant: "system", available: true, path: "/bin/zsh", source: "standard-path" },
322 { id: "sh", variant: "system", available: true, path: "/bin/sh", source: "standard-path" },
323 ],
324 gitCapability: { id: "git", available: false, reason: "not-found" },
325 shellInstallAction: null,
326 shellRepairGuidance: null,
327 gitRepairGuidance: { manager: "homebrew", command: "brew install git" },
328 };
329 const desktopStub = installDesktopHostStub(({
330 main: {
331 App: {
332 Settings: async () => macSettings,
333 SetShellPreference: async () => {},
334 InstallShellSupport: async () => ({ status: "unsupported_platform" }),
335 CancelShellInstall: async () => {},
336 ReloadSettings: async () => {},
337 } as Partial<AppBindings> as AppBindings,
338 },
339 }).main.App, { externalOpens: openedURLs });
340 await act(async () => {
341 root.render(
342 <LocaleProvider>
343 <SettingsPanel initialTab="sandbox" desktopPlatform="darwin" onClose={() => {}} onChanged={() => {}} />
344 </LocaleProvider>,
345 );
346 await flushPromises();
347 });
348 await waitFor("macOS shell inventory", () => rootEl.textContent?.includes("POSIX sh") === true);
349 eq(await shellOptionValues(rootEl), ["auto", "bash"],
350 "macOS selector contains no PowerShell runtimes");
351 ok(rootEl.textContent?.includes("zsh") === true && rootEl.textContent?.includes("POSIX sh") === true,
352 "macOS detection reports native zsh and POSIX sh");
353 ok(!rootEl.textContent?.includes("brew install bash") && !rootEl.textContent?.includes("Bash is not detected"),
354 "macOS native zsh fallback does not request a Bash install");
355 ok(rootEl.textContent?.includes("Git") === true && rootEl.textContent?.includes("brew install git") === true,
356 "macOS missing Git shows an independent Homebrew Git repair command");
357 ok(rootEl.textContent?.includes("Shell after reload") === false,
358 "identical current and resolved shells do not produce duplicate status rows");
359 const gitCopyButton = Array.from(rootEl.querySelectorAll("button")).find((button) => button.textContent?.includes("Copy command"));
360 await act(async () => {
361 gitCopyButton!.click();
362 await flushPromises();
363 });
364 eq(copiedCommands.at(-1), "brew install git", "macOS Git repair copies brew install git only");
365 ok(!Array.from(rootEl.querySelectorAll("button")).some((button) => button.textContent?.includes("Install Git for Windows")),
366 "macOS never renders the Windows install entry");
367 await act(async () => { root.unmount(); });
368 }
369
370 // Drafts survive failed saves, a pending write cannot be submitted twice, and
371 // a newly loaded authoritative root replaces the previous displayed value.
372 {
373 const rootEl = document.createElement("div");
374 document.body.appendChild(rootEl);
375 const root = createRoot(rootEl);
376 const settings = windowsSettings({});
377 let writes = 0;
378 let failSave = true;
379 let release: (() => void) | undefined;
380 installDesktopHostStub({
381 Settings: async () => structuredClone(settings),
382 ReloadSettings: async () => { settings.sandbox.workspaceRoot = "C:\\Reloaded"; },
383 SetSandbox: async (_bash: string, _network: boolean, workspaceRoot: string, allowWrite: string[]) => {
384 writes++;
385 if (failSave) { await new Promise<void>(resolve => { release = resolve; }); throw new Error("save rejected"); }
386 settings.sandbox.workspaceRoot = workspaceRoot.trim();
387 settings.sandbox.allowWrite = allowWrite;
388 },
389 } as Partial<AppBindings> as AppBindings);
390 await act(async () => { root.render(<LocaleProvider><SettingsPanel initialTab="sandbox" desktopPlatform="windows" onClose={() => {}} onChanged={() => {}} /></LocaleProvider>); await flushPromises(); });
391 const input = () => rootEl.querySelector<HTMLInputElement>('[aria-label="Additional writable directories"]')!;
392 const rootInput = () => rootEl.querySelector<HTMLInputElement>('[aria-label="Workspace root"]')!;
393 const change = async (element: HTMLInputElement, value: string) => {
394 await act(async () => {
395 element.focus();
396 const previous = element.value;
397 element.value = value;
398 (element as HTMLInputElement & { _valueTracker?: { setValue: (next: string) => void } })._valueTracker?.setValue(previous);
399 element.dispatchEvent(new Event("input", { bubbles: true }));
400 element.dispatchEvent(new Event("change", { bubbles: true }));
401 element.dispatchEvent(new KeyboardEvent("keyup", { key: "a", bubbles: true }));
402 await flushPromises();
403 });
404 };
405 const enter = (element: HTMLInputElement) => element.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
406 await change(input(), "C:\\Extra");
407 await act(async () => { enter(input()); enter(input()); await flushPromises(); });
408 eq(writes, 1, "rapid Enter submits a rule only once");
409 ok(input().disabled, "rule draft is disabled while saving");
410 await act(async () => { release!(); await flushPromises(); });
411 eq(input().value, "C:\\Extra", "failed rule save preserves the entered directory");
412 failSave = false;
413 await act(async () => { enter(input()); await flushPromises(); });
414 eq(input().value, "", "successful retry clears the directory draft");
415 eq(settings.sandbox.allowWrite, ["C:\\Extra"], "successful retry stores exactly one directory");
416 const reload = Array.from(rootEl.querySelectorAll("button")).find(b => b.textContent === "Reload configuration")!;
417 await act(async () => { reload.click(); await flushPromises(); });
418 eq(rootInput().value, "C:\\Reloaded", "reloaded configuration updates the root field without remounting");
419 failSave = true;
420 await change(rootInput(), "C:\\Edited");
421 await act(async () => { enter(rootInput()); await flushPromises(); });
422 eq(writes, 3, "root Enter starts a distinct save of the edited value");
423 await act(async () => { release!(); await flushPromises(); });
424 eq(rootInput().value, "C:\\Edited", "failed root save retains the editable draft");
425 await waitFor("failed root save settles", () => !rootInput().disabled);
426 eq(rootEl.querySelector(".sandbox-save-status")?.textContent, "Save failed. Please retry.", "failed saves never show saved status");
427 failSave = false;
428 await act(async () => { enter(rootInput()); await flushPromises(); });
429 eq(settings.sandbox.workspaceRoot, "C:\\Edited", "root draft can be retried successfully");
430 await change(rootInput(), "");
431 await act(async () => { enter(rootInput()); await flushPromises(); });
432 eq(settings.sandbox.workspaceRoot, "", "an empty root restores the current-workspace default");
433 eq(rootInput().value, "", "saved empty root stays empty after refresh");
434 await act(async () => { root.unmount(); });
435
436 // The same RuleList owns permission rules; its failure behavior must agree.
437 const permissionsRoot = createRoot(rootEl);
438 let permissionWrites = 0;
439 failSave = true;
440 installDesktopHostStub({
441 Settings: async () => structuredClone(settings),
442 AddPermissionRule: async (list: "allow" | "ask" | "deny", rule: string) => {
443 permissionWrites++;
444 if (failSave) throw new Error("permission save rejected");
445 settings.permissions[list].push(rule);
446 },
447 } as Partial<AppBindings> as AppBindings);
448 await act(async () => { permissionsRoot.render(<LocaleProvider><SettingsPanel initialTab="permissions" desktopPlatform="windows" onClose={() => {}} onChanged={() => {}} /></LocaleProvider>); await flushPromises(); });
449 const permissionInput = rootEl.querySelector<HTMLInputElement>('.set-rules input')!;
450 await change(permissionInput, "Bash(rm:*)");
451 await act(async () => { enter(permissionInput); enter(permissionInput); await flushPromises(); });
452 eq(permissionWrites, 1, "permission rules also reject duplicate concurrent Enter");
453 eq(permissionInput.value, "Bash(rm:*)", "failed permission rules preserve their draft too");
454 failSave = false;
455 await act(async () => { enter(permissionInput); await flushPromises(); });
456 eq(permissionInput.value, "", "successful permission retry clears the shared rule draft");
457 await act(async () => { permissionsRoot.unmount(); });
458 }
459
460 if (failed > 0) {
461 console.error(`\n${failed} failed, ${passed} passed`);
462 process.exit(1);
463 }
464 console.log(`\n${passed} passed, 0 failed`);
465
465 lines Plain Text