diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index e02b585b83..d93dcb9dd8 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -14,6 +14,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/1917[#1917]: Allow the creation of a desktop shortcut for the GUI * https://github.com/devonfw/IDEasy/issues/2134[#2134]: Add auto-completion for icd command * https://github.com/devonfw/IDEasy/issues/2145[#2145]: Improve documentation on symlink regarding ide ln +* https://github.com/devonfw/IDEasy/issues/2145[#218]: Added possibility to start a command in a new window using the process builder The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/48?closed=1[milestone 2026.08.001]. diff --git a/cli/src/main/java/com/devonfw/tools/ide/process/ProcessContextImpl.java b/cli/src/main/java/com/devonfw/tools/ide/process/ProcessContextImpl.java index ade1677371..78f4587ed8 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/process/ProcessContextImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/process/ProcessContextImpl.java @@ -25,8 +25,6 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.environment.VariableLine; import com.devonfw.tools.ide.log.IdeLogLevel; -import com.devonfw.tools.ide.os.SystemInfoImpl; -import com.devonfw.tools.ide.os.WindowsPathSyntax; import com.devonfw.tools.ide.util.FilenameUtil; import com.devonfw.tools.ide.variable.IdeVariables; @@ -203,7 +201,7 @@ public ProcessResult run(ProcessMode processMode) { try { applyRedirects(processMode); if (processMode.isBackground()) { - modifyArgumentsOnBackgroundProcess(processMode); + modifyArgumentsOnBackgroundProcess(processMode, args); } this.processBuilder.command(args); @@ -239,7 +237,8 @@ public ProcessResult run(ProcessMode processMode) { List finalOutput = new ArrayList<>(output); boolean success = this.exitCodeAcceptor.test(exitCode); - ProcessResult result = new ProcessResultImpl(this.executable.getFileName().toString(), command, exitCode, success, finalOutput); + ProcessResult result = + new ProcessResultImpl(this.executable.getFileName().toString(), command, exitCode, success, finalOutput); performLogging(result, exitCode, interpreter); @@ -269,9 +268,11 @@ public ProcessResult run(ProcessMode processMode) { * * @param is {@link InputStream}. * @param errorStream to identify if the output came from stdout or stderr + * @param outputMessages the queue storing output messages * @return {@link CompletableFuture}. */ - private static CompletableFuture readInputStream(InputStream is, boolean errorStream, ConcurrentLinkedQueue outputMessages) { + private static CompletableFuture readInputStream(InputStream is, boolean errorStream, + ConcurrentLinkedQueue outputMessages) { return CompletableFuture.supplyAsync(() -> { @@ -405,9 +406,37 @@ private void performLogging(ProcessResult result, int exitCode, String interpret } } - private void modifyArgumentsOnBackgroundProcess(ProcessMode processMode) { + /** + * Modifies the argument list to run the command as a background process. On Linux/macOS, uses {@code bash -c} with {@code & disown} for detachment. On + * Windows, uses {@code cmd.exe /c} with {@code start /b} for detachment or {@code start} for a new window. + * + * @param processMode the {@link ProcessMode} determining the background behavior + * @param args the argument list to modify in place + */ + private void modifyArgumentsOnBackgroundProcess(ProcessMode processMode, List args) { + + if (!processMode.isBackground()) { + throw new IllegalStateException( + "modifyArgumentsOnBackgroundProcess called for a non-background process."); + } - assert processMode.isBackground() : "Cannot handle non background process mode!"; + String commandToRunInBackground = buildCommand(args); + + if (this.context.getSystemInfo().isWindows()) { + modifyArgumentsOnBackgroundProcessWindows(processMode, args, commandToRunInBackground); + } else { + modifyArgumentsOnBackgroundProcessUnix(processMode, args, commandToRunInBackground); + } + } + + /** + * Modifies arguments for a background process on Linux/macOS using {@code bash -c}. + * + * @param processMode the {@link ProcessMode} determining the background behavior + * @param args the argument list to modify in place + * @param command the command string to run in the background + */ + private void modifyArgumentsOnBackgroundProcessUnix(ProcessMode processMode, List args, String command) { Path bash = this.context.findBash(); if (bash == null) { @@ -416,14 +445,230 @@ private void modifyArgumentsOnBackgroundProcess(ProcessMode processMode) { return; } - String commandToRunInBackground = buildCommandToRunInBackground(); + args.clear(); + args.add(bash.toString()); + args.add("-c"); + + if (processMode.launchesNewWindow()) { + String newWindowCommand = buildNewWindowCommand(command); + args.add(newWindowCommand + " ; disown"); + } else { + args.add(command + " & disown"); + } + } + + /** + * Modifies arguments for a background process on Windows using {@code cmd.exe /c}. + * + * @param processMode the {@link ProcessMode} determining the background behavior + * @param args the argument list to modify in place + * @param command the command string to run in the background + */ + private void modifyArgumentsOnBackgroundProcessWindows(ProcessMode processMode, List args, String command) { - this.arguments.clear(); - this.arguments.add(bash.toString()); - this.arguments.add("-c"); - commandToRunInBackground += " & disown"; - this.arguments.add(commandToRunInBackground); + args.clear(); + args.add("cmd.exe"); + args.add("/c"); + if (processMode.launchesNewWindow()) { + args.add("start \"\" cmd.exe /k " + command); + } else { + // start /b detaches the process without opening a new window + args.add("start \"\" /b " + command); + } + } + + private String buildCommand(List args) { + + if (this.context.getSystemInfo().isWindows()) { + return args.stream() + .map(this::windowsQuote) + .collect(Collectors.joining(" ")); + } else { + return args.stream() + .map(this::shellQuote) + .collect(Collectors.joining(" ")); + } + } + + /** + * Build the command for opening a new terminal window on Linux/macOS. + * + * @param command the command to run in the new terminal + * @return the shell command string that opens a new terminal window, or a fallback background command + */ + private String buildNewWindowCommand(String command) { + + if (this.context.getSystemInfo().isLinux()) { + return buildLinuxNewWindowCommand(command); + } + + if (this.context.getSystemInfo().isMac()) { + return buildMacOsNewWindowCommand(command); + } + + // Fallback for unsupported platforms + LOG.warn( + "No terminal emulator detected for BACKGROUND_NEW_WINDOW on {} - falling back to background execution without new window.", + this.context.getSystemInfo().getOsName()); + return command + " & disown"; + } + + /** + * Build the command for opening a new terminal window on Linux. Detects available terminal emulators and uses the correct flag syntax for each. + * + * @param command the command to run in the new terminal + * @return the shell command string that opens a new terminal window, or a fallback background command + */ + private String buildLinuxNewWindowCommand(String command) { + + String bashCommand = "bash -c " + shellQuote(command + "; exec bash"); + + // Prefer explicit terminal emulators over x-terminal-emulator because + // x-terminal-emulator is only an alternatives symlink and may point to + // different terminals with different command-line syntax. + if (isExecutable("gnome-terminal")) { + return "gnome-terminal -- " + bashCommand + " &"; + } + + if (isExecutable("konsole")) { + return "konsole -e " + bashCommand + " &"; + } + + if (isExecutable("xfce4-terminal")) { + return "xfce4-terminal --command=" + shellQuote(bashCommand) + " &"; + } + + if (isExecutable("tilix")) { + return "tilix -e " + bashCommand + " &"; + } + + if (isExecutable("alacritty")) { + return "alacritty -e " + bashCommand + " &"; + } + + if (isExecutable("xterm")) { + return "xterm -e " + bashCommand + " &"; + } + + // Last fallback only. This may still fail depending on what the alternatives + // symlink points to, but it is better than not trying at all. + if (isExecutable("x-terminal-emulator")) { + return "x-terminal-emulator -e " + bashCommand + " &"; + } + + LOG.warn("No terminal emulator found on Linux - falling back to background execution without new window."); + + return "bash -c " + shellQuote(command) + " > /dev/null 2>&1 &"; + } + + private String shellQuote(String value) { + + if (value == null || value.isEmpty()) { + return "''"; + } + return "'" + escapeForShellSingleQuote(value) + "'"; + } + + private String windowsQuote(String value) { + + if (value == null || value.isEmpty()) { + return "\"\""; + } + + // Quote unconditionally and escape cmd.exe metacharacters + String escaped = value.replace("^", "^^").replace("%", "%%").replace("\"", "\\\""); + return "\"" + escaped + "\""; + } + + /** + * Build the command for opening a new terminal window on macOS. Prefers iTerm2 via AppleScript, falls back to Terminal.app, then to plain background. + * + * @param command the command to run in the new terminal + * @return the shell command string that opens a new terminal window, or a fallback background command + */ + private String buildMacOsNewWindowCommand(String command) { + + // Escape for AppleScript string literal: backslashes first, then double quotes + String escapedForAppleScript = command.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + + // Check for iTerm2, modern versions ship as /Applications/iTerm.app + if (isItermInstalled()) { + String appleScript = "tell application \"iTerm2\"\n" + + " activate\n" + + " set newWindow to (create window with default profile)\n" + + " tell current session of newWindow\n" + + " write text \"" + escapedForAppleScript + "; exec bash\"\n" + + " end tell\n" + + "end tell"; + return "osascript -e '" + escapeForShellSingleQuote(appleScript) + "' &"; + } + + // Fallback to Terminal.app + String terminalScript = "tell application \"Terminal\"\n" + + " do script \"" + escapedForAppleScript + "\"\n" + + "end tell"; + return "osascript -e '" + escapeForShellSingleQuote(terminalScript) + "' &"; + } + + /** + * Check if iTerm2 is installed on macOS. + * + * @return {@code true} if iTerm2 is found + */ + private boolean isItermInstalled() { + + // Modern iTerm2 3.x+ is installed as /Applications/iTerm.app + if (Files.exists(Path.of("/Applications/iTerm.app"))) { + return true; + } + // Older iTerm2 versions used /Applications/iTerm2.app + if (Files.exists(Path.of("/Applications/iTerm2.app"))) { + return true; + } + // Check ~/Applications for per-user installs + Path homeApps = Path.of(System.getProperty("user.home"), "Applications"); + if (Files.exists(homeApps.resolve("iTerm.app"))) { + return true; + } + if (Files.exists(homeApps.resolve("iTerm2.app"))) { + return true; + } + // Also check if iTerm binary is in PATH, for portable installs, Homebrew, etc. + return isExecutable("iterm"); + } + + /** + * Escape a string for embedding inside a shell single-quoted string. Single quotes are replaced with the standard shell sequence: {@code '"'"'} + * + * @param s the string to escape + * @return the escaped string safe for single-quote embedding + */ + private static String escapeForShellSingleQuote(String s) { + + return s.replace("'", "'\"'\"'"); + } + + /** + * Check if a command is available in PATH. + * + * @param command the command name + * @return {@code true} if the command is found in PATH + */ + private boolean isExecutable(String command) { + + SystemPath systemPath = this.context.getPath(); + Path binary = systemPath.findBinary(Path.of(command)); + + try { + return (binary != null) && Files.isExecutable(binary); + } catch (Exception e) { + return false; + } } private void applyRedirects(ProcessMode processMode) { @@ -442,25 +687,4 @@ private void applyRedirects(ProcessMode processMode) { this.processBuilder.redirectInput(input); } } - - private String buildCommandToRunInBackground() { - - if (this.context.getSystemInfo().isWindows()) { - - StringBuilder stringBuilder = new StringBuilder(); - - for (String argument : this.arguments) { - - if (SystemInfoImpl.INSTANCE.isWindows() && SystemPath.isValidWindowsPath(argument)) { - argument = WindowsPathSyntax.MSYS.normalize(argument); - } - - stringBuilder.append(argument); - stringBuilder.append(" "); - } - return stringBuilder.toString().trim(); - } else { - return this.arguments.stream().map(Object::toString).collect(Collectors.joining(" ")); - } - } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/process/ProcessMode.java b/cli/src/main/java/com/devonfw/tools/ide/process/ProcessMode.java index b2a85733d6..7e1c813438 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/process/ProcessMode.java +++ b/cli/src/main/java/com/devonfw/tools/ide/process/ProcessMode.java @@ -4,7 +4,7 @@ /** * The ProcessMode defines how to start the command process and how output streams are handled using {@link ProcessBuilder}. Modes that can be used: - * {@link #BACKGROUND} {@link #BACKGROUND_SILENT} {@link #DEFAULT} {@link #DEFAULT_CAPTURE} + * {@link #BACKGROUND} {@link #BACKGROUND_SILENT} {@link #BACKGROUND_NEW_WINDOW} {@link #DEFAULT} {@link #DEFAULT_CAPTURE} */ public enum ProcessMode { /** @@ -50,6 +50,27 @@ public Redirect getRedirectInput() { return null; } }, + /** + * Like {@link #BACKGROUND_SILENT} but opens a new terminal window for the process. The new window captures the subprocess output and error streams, so they + * are discarded from the parent process perspective using {@link ProcessBuilder.Redirect#DISCARD}. The parent process does not wait for the child and the + * child survives if the parent terminates. + */ + BACKGROUND_NEW_WINDOW { + @Override + public Redirect getRedirectOutput() { + return Redirect.DISCARD; + } + + @Override + public Redirect getRedirectError() { + return Redirect.DISCARD; + } + + @Override + public Redirect getRedirectInput() { + return null; + } + }, /** * The process will be started according {@link ProcessBuilder.Redirect#INHERIT} without any detaching of parent process and child process. This setting makes * the child process dependant from the parent process! (If you close the parent process the child process will also be terminated.) @@ -142,17 +163,25 @@ public Redirect getRedirectInput() { */ public abstract Redirect getRedirectInput(); - /** * Method to check if the ProcessMode is a background process. * - * @return {@code true} if the {@link ProcessMode} is {@link ProcessMode#BACKGROUND} or {@link ProcessMode#BACKGROUND_SILENT}, {@code false} if not. + * @return {@code true} if the {@link ProcessMode} is {@link ProcessMode#BACKGROUND}, {@link ProcessMode#BACKGROUND_SILENT}, or + * {@link ProcessMode#BACKGROUND_NEW_WINDOW}, {@code false} if not. */ public boolean isBackground() { - return this == BACKGROUND || this == BACKGROUND_SILENT; + return this == BACKGROUND || this == BACKGROUND_SILENT || this == BACKGROUND_NEW_WINDOW; } - // TODO ADD EXTERNAL_WINDOW_MODE IN FUTURE Issue: https://github.com/devonfw/IDEasy/issues/218 + /** + * Method to check if the ProcessMode should launch the process in a new terminal window. + * + * @return {@code true} if this mode should launch the process in a new terminal window, {@code false} otherwise. + */ + public boolean launchesNewWindow() { + + return this == BACKGROUND_NEW_WINDOW; + } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/context/ProcessContextTestImpl.java b/cli/src/test/java/com/devonfw/tools/ide/context/ProcessContextTestImpl.java index 1d1d63ec16..38f311a342 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/context/ProcessContextTestImpl.java +++ b/cli/src/test/java/com/devonfw/tools/ide/context/ProcessContextTestImpl.java @@ -31,7 +31,8 @@ public ProcessContext createChild() { public ProcessResult run(ProcessMode processMode) { ProcessResult result = super.run(ProcessMode.DEFAULT_CAPTURE); // this hack is still required to capture test script output - if (result.isSuccessful() && (processMode == ProcessMode.DEFAULT || processMode == ProcessMode.BACKGROUND)) { + if (result.isSuccessful() && (processMode == ProcessMode.DEFAULT || processMode == ProcessMode.BACKGROUND + || processMode == ProcessMode.BACKGROUND_NEW_WINDOW)) { result.log(IdeLogLevel.INFO); } return result; diff --git a/cli/src/test/java/com/devonfw/tools/ide/process/BackgroundNewWindowTest.java b/cli/src/test/java/com/devonfw/tools/ide/process/BackgroundNewWindowTest.java new file mode 100644 index 0000000000..54304e623c --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/process/BackgroundNewWindowTest.java @@ -0,0 +1,386 @@ +package com.devonfw.tools.ide.process; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.platform.commons.util.ReflectionUtils; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; + +/** + * Integration tests for {@link ProcessMode#BACKGROUND_NEW_WINDOW}. + *

+ * These tests verify that the new background-new-window mode generates the correct bash command structure with terminal emulator detection on Linux, + * {@code osascript} on macOS, and {@code cmd.exe /c start} on Windows. + */ +class BackgroundNewWindowTest extends AbstractIdeContextTest { + + /** + * Verify that {@link ProcessMode#BACKGROUND_NEW_WINDOW} returns true for {@link ProcessMode#isBackground()}. + */ + @Test + void backgroundNewWindowShouldBeConsideredBackground() { + assertThat(ProcessMode.BACKGROUND_NEW_WINDOW.isBackground()).isTrue(); + } + + /** + * Verify that {@link ProcessMode#BACKGROUND_NEW_WINDOW} returns true for {@link ProcessMode#launchesNewWindow()}. + */ + @Test + void backgroundNewWindowShouldLaunchNewWindow() { + assertThat(ProcessMode.BACKGROUND_NEW_WINDOW.launchesNewWindow()).isTrue(); + } + + /** + * Verify that all other modes return false for {@link ProcessMode#launchesNewWindow()}. + */ + @Test + void otherModesShouldNotLaunchNewWindow() { + for (ProcessMode mode : ProcessMode.values()) { + if (mode != ProcessMode.BACKGROUND_NEW_WINDOW) { + assertThat(mode.launchesNewWindow()).as(String.format("Mode %s should not launch new window", mode)).isFalse(); + } + } + } + + /** + * Verify that {@link ProcessMode#BACKGROUND_NEW_WINDOW} discards output and error, like {@link ProcessMode#BACKGROUND_SILENT}. + */ + @Test + void backgroundNewWindowShouldDiscardOutputAndError() { + assertThat(ProcessMode.BACKGROUND_NEW_WINDOW.getRedirectOutput()).isEqualTo(ProcessBuilder.Redirect.DISCARD); + assertThat(ProcessMode.BACKGROUND_NEW_WINDOW.getRedirectError()).isEqualTo(ProcessBuilder.Redirect.DISCARD); + assertThat(ProcessMode.BACKGROUND_NEW_WINDOW.getRedirectInput()).isNull(); + } + + /** + * Verify that on Linux, {@link ProcessMode#BACKGROUND_NEW_WINDOW} generates a bash command containing a terminal emulator ({@code x-terminal-emulator}, + * {@code gnome-terminal}, or another detected terminal). Uses a mocked {@link ProcessBuilder} to capture the command list without executing the process. + */ + @Test + @EnabledOnOs(OS.LINUX) + void shouldContainTerminalEmulatorCommandOnLinux() throws Exception { + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, null, false); + if (context.findBash() == null) { + return; // Skip when no bash is available + } + + Path scriptPath = TEST_RESOURCES.resolve("process-context").resolve("log-order.sh"); + // act + List capturedArgs = captureCommandArgs(context, scriptPath, ProcessMode.BACKGROUND_NEW_WINDOW); + // assert + assertThat(capturedArgs).as("Captured args should not be empty").isNotEmpty(); + + // The first argument should be bash + assertThat(capturedArgs.get(0)).as("First arg should be bash path").contains("bash"); + + // The second argument should be -c (bash command flag) + assertThat(capturedArgs.get(1)).isEqualTo("-c"); + + // The third argument should contain a terminal emulator command or fallback + String bashCommand = capturedArgs.get(2); + boolean hasTerminalEmulator = bashCommand.contains("x-terminal-emulator") || bashCommand.contains("gnome-terminal") + || bashCommand.contains("konsole") || bashCommand.contains("xfce4-terminal") + || bashCommand.contains("tilix") || bashCommand.contains("xterm") || bashCommand.contains("alacritty"); + boolean hasFallback = bashCommand.contains("disown"); + + // Should either have a terminal emulator or fallback to disown + assertThat(hasTerminalEmulator || hasFallback) + .as("Bash command should contain a terminal emulator or the disown fallback").isTrue(); + + if (hasTerminalEmulator) { + // When a terminal emulator is found, the command should keep the window open and background it + assertThat(bashCommand).as("Bash command should contain 'exec bash' to keep terminal open") + .contains("exec bash"); + assertThat(bashCommand).as("Bash command should end with & for backgrounding").endsWith("&"); + } + } + + /** + * Verify that on Windows, {@link ProcessMode#BACKGROUND_NEW_WINDOW} generates a command using {@code cmd.exe /c start} to open a new CMD window. + */ + @Test + @EnabledOnOs(OS.WINDOWS) + void shouldContainStartCommandOnWindows() throws Exception { + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, null, false); + + Path scriptPath = TEST_RESOURCES.resolve("process-context").resolve("log-order.sh"); + // act + List capturedArgs = captureCommandArgs(context, scriptPath, ProcessMode.BACKGROUND_NEW_WINDOW); + // assert + assertThat(capturedArgs).as("Captured args should not be empty").isNotEmpty(); + + // First argument should be cmd.exe + assertThat(capturedArgs.get(0)).as("First arg should be cmd.exe").contains("cmd.exe"); + + // Second argument should be /c + assertThat(capturedArgs.get(1)).isEqualTo("/c"); + + // Third argument should contain the 'start' command for a new window + String startCommand = capturedArgs.get(2); + assertThat(startCommand).as("Command should contain /k to keep window open").contains("/k"); + assertThat(startCommand).as("Command should NOT contain disown on Windows").doesNotContain("disown"); + assertThat(startCommand).as("Command should NOT contain x-terminal-emulator on Windows") + .doesNotContain("x-terminal-emulator"); + } + + /** + * Verify that on macOS, {@link ProcessMode#BACKGROUND_NEW_WINDOW} uses {@code osascript} to launch either iTerm2 or Terminal.app instead of falling back to + * {@code & disown}. + */ + @Test + @EnabledOnOs(OS.MAC) + void shouldUseOsascriptOnMac() throws Exception { + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, null, false); + if (context.findBash() == null) { + return; // Skip when no bash is available + } + + Path scriptPath = TEST_RESOURCES.resolve("process-context").resolve("log-order.sh"); + // act + List capturedArgs = captureCommandArgs(context, scriptPath, ProcessMode.BACKGROUND_NEW_WINDOW); + // assert + assertThat(capturedArgs).as("Captured args should not be empty").isNotEmpty(); + + // The bash command should contain osascript (macOS uses AppleScript for terminal control) + String bashCommand = capturedArgs.get(2); + assertThat(bashCommand).as("Bash command should contain osascript on macOS").contains("osascript"); + assertThat(bashCommand).as("Bash command should contain Terminal or iTerm2 AppleScript") + .satisfiesAnyOf( + s -> assertThat(s).contains("iTerm2"), + s -> assertThat(s).contains("Terminal")); + // Should NOT fall back to plain disown anymore + assertThat(bashCommand).as("Bash command should NOT contain disown on macOS (has terminal support)") + .doesNotContain("disown"); + } + + /** + * Sets up a mocked {@link ProcessBuilder} on the given {@link ProcessContextImpl} to capture the command list passed to {@link ProcessBuilder#command(List)}, + * then runs the process with the given mode. + * + * @param context the {@link IdeTestContext} + * @param script the script path to set as executable + * @param mode the {@link ProcessMode} to run with + * @return the list of arguments captured from {@code command(List)} + */ + private List captureCommandArgs(IdeTestContext context, Path script, ProcessMode mode) throws Exception { + + List capturedArgs = new ArrayList<>(); + + ProcessBuilder mockPb = mock(ProcessBuilder.class); + when(mockPb.command(anyList())).thenAnswer(invocation -> { + capturedArgs.addAll(invocation.getArgument(0)); + return mockPb; + }); + + ProcessContextImpl processContext = new ProcessContextImpl(context); + + Field pbField = ReflectionUtils.findFields(ProcessContextImpl.class, f -> f.getName().equals("processBuilder"), + ReflectionUtils.HierarchyTraversalMode.TOP_DOWN).get(0); + pbField.setAccessible(true); + pbField.set(processContext, mockPb); + pbField.setAccessible(false); + + processContext.executable(script); + + try { + processContext.run(mode); + } catch (Exception e) { + // Expected — the mock throws when start() is called + } + + return capturedArgs; + } + + /** + * End-to-end verification: on Windows (CMD), {@link ProcessMode#BACKGROUND_NEW_WINDOW} should open a new CMD window via {@code start} and actually execute + * the command. The marker file proves the subprocess was spawned in the new window and completed. + *

+ * Uses a polling approach with a generous timeout to handle cold-start delays on CI VMs. + */ + @Test + @EnabledOnOs(OS.WINDOWS) + void backgroundNewWindowShouldActuallyExecuteViaStartOnWindows() throws Exception { + // arrange + if (isCiEnvironment()) { + return; // Skip — opening a CMD window would leak in CI + } + IdeTestContext context = newContext(PROJECT_BASIC, null, false); + + Path markerFile = Files.createTempFile("bg-marker-", ".txt"); + Files.delete(markerFile); // Remove so polling can detect when the batch file creates it + + // Create a simple batch file that writes to the marker file + Path batchFile = Files.createTempFile("bg-test-", ".bat"); + String batchContent = "@echo off\r\necho background-process-ran > " + markerFile + "\r\nexit /b 0\r\n"; + Files.writeString(batchFile, batchContent); + + ProcessContextImpl processContext = new ProcessContextImpl(context); + // act + ProcessResult result = processContext.executable(batchFile) + .run(ProcessMode.BACKGROUND_NEW_WINDOW); + // assert + // The process should detach successfully + assertThat(result.isSuccessful()).isTrue(); + + // Poll for the marker file with a generous timeout. + long timeout = System.currentTimeMillis() + Duration.ofSeconds(15).toMillis(); + while (System.currentTimeMillis() < timeout) { + if (Files.exists(markerFile)) { + assertThat(Files.readString(markerFile)).contains("background-process-ran"); + Files.delete(batchFile); // Clean up + return; // Success + } + Thread.sleep(Duration.ofMillis(500)); + } + + // If we reach here, the marker file was not created in time + assertThat(markerFile) + .as("Marker file should have been created by the new-window CMD process within 15 seconds") + .exists(); + Files.deleteIfExists(batchFile); + } + + /** + * End-to-end verification: on Linux, {@link ProcessMode#BACKGROUND_NEW_WINDOW} should open a new terminal window (via {@code gnome-terminal}, {@code xterm}, + * or another detected emulator) and actually execute the command. The marker file proves the subprocess was spawned in the new window and completed. + *

+ * If no display is available (CI, headless server), the fallback {@code disown} path is tested instead — it still proves the command executes in background. + */ + @Test + @EnabledOnOs(OS.LINUX) + void backgroundNewWindowShouldActuallyExecuteOnLinux() throws Exception { + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, null, false); + if (context.findBash() == null) { + return; // Skip when no bash is available + } + + // Pre-answer "yes" to the executable permission prompt that may appear in CI + // where git clone does not preserve executable permissions on shell scripts + context.setAnswers("1"); + + boolean hasDisplay = System.getenv("DISPLAY") != null || System.getenv("WAYLAND_DISPLAY") != null; + + Path markerFile = Files.createTempFile("bg-marker-linux-", ".txt"); + Files.delete(markerFile); // Remove so polling can detect when the script creates it + Path scriptPath = TEST_RESOURCES.resolve("process-context").resolve("write-marker.sh"); + + ProcessContextImpl processContext = new ProcessContextImpl(context); + // act + ProcessResult result = processContext.executable(scriptPath).addArg(markerFile.toString()) + .run(ProcessMode.BACKGROUND_NEW_WINDOW); + // assert + assertThat(result.isSuccessful()).isTrue(); + + // Poll for the marker file with a generous timeout. + long timeout = System.currentTimeMillis() + Duration.ofSeconds(15).toMillis(); + while (System.currentTimeMillis() < timeout) { + if (Files.exists(markerFile)) { + assertThat(Files.readString(markerFile)).contains("background-process-ran"); + return; + } + Thread.sleep(Duration.ofMillis(500)); + } + + // If we reach here, the marker file was not created in time + if (hasDisplay) { + assertThat(markerFile) + .as("Marker file should have been created by the new-window terminal process within 15 seconds") + .exists(); + } + // Without a display, the fallback "disown" path may or may not succeed depending on the environment; + // we don't fail the test in that case since the structural test (shouldContainTerminalEmulatorCommandOnLinux) covers command shape. + } + + /** + * End-to-end verification: on macOS, {@link ProcessMode#BACKGROUND_NEW_WINDOW} should open a new terminal window via {@code osascript} (iTerm2 or + * Terminal.app) and actually execute the command. The marker file proves the subprocess was spawned in the new window and completed. + *

+ * Uses a polling approach with a generous timeout since Terminal.app cold-start can take 2-6 seconds on CI VMs. + */ + @Test + @EnabledOnOs(OS.MAC) + void backgroundNewWindowShouldActuallyExecuteViaOsascriptOnMac() throws Exception { + // arrange + if (isCiEnvironment()) { + return; // Skip — opening a terminal window would leak in CI + } + IdeTestContext context = newContext(PROJECT_BASIC, null, false); + if (context.findBash() == null) { + return; // Skip when no bash is available + } + + // Verify osascript is available (required for Terminal.app / iTerm2 AppleScript) + if (!isOsascriptAvailable()) { + // Skip - no GUI session or osascript not available (e.g. headless CI) + return; + } + + Path markerFile = Files.createTempFile("bg-marker-mac-", ".txt"); + Files.delete(markerFile); // Remove so polling can detect when the script creates it + Path scriptPath = TEST_RESOURCES.resolve("process-context").resolve("write-marker.sh"); + + ProcessContextImpl processContext = new ProcessContextImpl(context); + // act + ProcessResult result = processContext.executable(scriptPath).addArg(markerFile.toString()) + .run(ProcessMode.BACKGROUND_NEW_WINDOW); + // assert + // The process should detach successfully + assertThat(result.isSuccessful()).isTrue(); + + // Poll for the marker file with a generous timeout. + long timeout = System.currentTimeMillis() + Duration.ofSeconds(15).toMillis(); + while (System.currentTimeMillis() < timeout) { + if (Files.exists(markerFile)) { + assertThat(Files.readString(markerFile)).contains("background-process-ran"); + return; // Success + } + Thread.sleep(Duration.ofMillis(500)); + } + + // If we reach here, the marker file was not created in time + assertThat(markerFile).as("Marker file should have been created by the new-window terminal process within 15 seconds") + .exists(); + } + + /** + * Check if {@code osascript} is available on macOS by attempting a trivial AppleScript call. This verifies both that the binary exists and that a GUI session + * is present. + */ + private boolean isOsascriptAvailable() { + try { + ProcessBuilder pb = new ProcessBuilder("osascript", "-e", "return 1"); + pb.redirectOutput(ProcessBuilder.Redirect.DISCARD); + pb.redirectError(ProcessBuilder.Redirect.DISCARD); + return pb.start().waitFor() == 0; + } catch (Exception e) { + return false; + } + } + + /** + * Check whether the test is running in a CI environment. E2E tests that open GUI windows should be skipped to avoid leaking windows in non-interactive CI + * runners. + */ + private static boolean isCiEnvironment() { + return System.getenv("CI") != null + || System.getenv("GITHUB_ACTIONS") != null + || System.getenv("JENKINS_URL") != null; + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/process/ProcessContextImplTest.java b/cli/src/test/java/com/devonfw/tools/ide/process/ProcessContextImplTest.java index 7a50001806..4d12d5c11d 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/process/ProcessContextImplTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/process/ProcessContextImplTest.java @@ -141,7 +141,7 @@ void enablingCaptureShouldRedirectAndCaptureStreamsCorrectly() throws Exception } @ParameterizedTest - @EnumSource(value = ProcessMode.class, names = { "BACKGROUND", "BACKGROUND_SILENT" }) + @EnumSource(value = ProcessMode.class, names = { "BACKGROUND", "BACKGROUND_SILENT", "BACKGROUND_NEW_WINDOW" }) void enablingBackgroundProcessShouldNotBeAwaitedAndShouldNotPassStreams(ProcessMode processMode) throws Exception { @@ -158,7 +158,7 @@ void enablingBackgroundProcessShouldNotBeAwaitedAndShouldNotPassStreams(ProcessM verify(this.mockProcessBuilder).redirectError( (ProcessBuilder.Redirect) argThat(arg -> arg.equals(ProcessBuilder.Redirect.INHERIT))); - } else if (processMode == ProcessMode.BACKGROUND_SILENT) { + } else if (processMode == ProcessMode.BACKGROUND_SILENT || processMode == ProcessMode.BACKGROUND_NEW_WINDOW) { verify(this.mockProcessBuilder).redirectOutput( (ProcessBuilder.Redirect) argThat(arg -> arg.equals(ProcessBuilder.Redirect.DISCARD))); diff --git a/cli/src/test/resources/process-context/write-marker.sh b/cli/src/test/resources/process-context/write-marker.sh new file mode 100644 index 0000000000..b9e8c62779 --- /dev/null +++ b/cli/src/test/resources/process-context/write-marker.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +# Writes a marker to stdout — used to verify background processes actually execute. +# The caller should provide $1 as the marker file path. +echo "background-process-ran" > "$1" \ No newline at end of file