Fix run preview process tree cleanup

This commit is contained in:
王鹏
2026-06-18 12:38:50 +08:00
parent 347a50e896
commit d34de73eba
5 changed files with 1101 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
package com.ruoyi.generator.service.front;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.concurrent.TimeUnit;
final class ManagedPreviewProcess extends Process
{
private final Process delegate;
private final File pidFile;
ManagedPreviewProcess(Process delegate, File pidFile)
{
this.delegate = delegate;
this.pidFile = pidFile;
}
long waitForRootPid(long timeoutMillis)
{
long deadline = System.currentTimeMillis() + timeoutMillis;
do
{
Long pid = readRootPid();
if (pid != null)
{
return pid.longValue();
}
sleepQuietly(25L);
}
while (System.currentTimeMillis() < deadline && delegate.isAlive());
return -1L;
}
void deletePidFile()
{
if (pidFile.isFile())
{
pidFile.delete();
}
}
private Long readRootPid()
{
if (!pidFile.isFile())
{
return null;
}
try
{
String value = new String(Files.readAllBytes(pidFile.toPath()), StandardCharsets.UTF_8).trim();
long pid = Long.parseLong(value);
return pid > 0L ? Long.valueOf(pid) : null;
}
catch (IOException ignored)
{
return null;
}
catch (NumberFormatException ignored)
{
return null;
}
}
private void sleepQuietly(long millis)
{
try
{
Thread.sleep(millis);
}
catch (InterruptedException e)
{
Thread.currentThread().interrupt();
}
}
@Override
public OutputStream getOutputStream()
{
return delegate.getOutputStream();
}
@Override
public InputStream getInputStream()
{
return delegate.getInputStream();
}
@Override
public InputStream getErrorStream()
{
return delegate.getErrorStream();
}
@Override
public int waitFor() throws InterruptedException
{
return delegate.waitFor();
}
@Override
public boolean waitFor(long timeout, TimeUnit unit) throws InterruptedException
{
return delegate.waitFor(timeout, unit);
}
@Override
public int exitValue()
{
return delegate.exitValue();
}
@Override
public void destroy()
{
delegate.destroy();
}
@Override
public Process destroyForcibly()
{
delegate.destroyForcibly();
return this;
}
@Override
public boolean isAlive()
{
return delegate.isAlive();
}
}

View File

@@ -0,0 +1,225 @@
package com.ruoyi.generator.service.front;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@Component
public class RunPreviewProcessKiller
{
private static final long GRACEFUL_WAIT_MILLIS = 1500L;
public void stop(Process process, Integer port)
{
stopProcess(process);
if (port != null)
{
killListeningProcesses(port.intValue());
}
}
private void stopProcess(Process process)
{
if (process == null)
{
return;
}
ManagedPreviewProcess managed = process instanceof ManagedPreviewProcess
? (ManagedPreviewProcess) process
: null;
try
{
if (!process.isAlive())
{
return;
}
if (managed != null)
{
long pid = managed.waitForRootPid(GRACEFUL_WAIT_MILLIS);
if (pid > 0L)
{
killWindowsProcessTree(pid);
waitForExit(process, GRACEFUL_WAIT_MILLIS);
}
}
if (process.isAlive())
{
process.destroy();
waitForExit(process, GRACEFUL_WAIT_MILLIS);
}
if (process.isAlive())
{
process.destroyForcibly();
waitForExit(process, GRACEFUL_WAIT_MILLIS);
}
}
finally
{
if (managed != null)
{
managed.deletePidFile();
}
}
}
protected boolean killWindowsProcessTree(long pid)
{
return executeQuietly(Arrays.asList("taskkill", "/PID", String.valueOf(pid), "/T", "/F"));
}
private void killListeningProcesses(int port)
{
if (isWindows())
{
killWindowsListeningProcesses(port);
}
else
{
executeQuietly(Arrays.asList("fuser", "-k", port + "/tcp"));
}
}
private void killWindowsListeningProcesses(int port)
{
for (Integer pid : findWindowsListeningPids(port))
{
killWindowsProcessTree(pid.longValue());
}
}
private List<Integer> findWindowsListeningPids(int port)
{
Set<Integer> pids = new LinkedHashSet<Integer>();
Process netstat = null;
BufferedReader reader = null;
try
{
netstat = new ProcessBuilder("netstat", "-ano", "-p", "tcp").redirectErrorStream(true).start();
reader = new BufferedReader(new InputStreamReader(netstat.getInputStream(), Charset.defaultCharset()));
String line;
while ((line = reader.readLine()) != null)
{
Integer pid = parseWindowsListeningPid(line, port);
if (pid != null)
{
pids.add(pid);
}
}
waitForExit(netstat, GRACEFUL_WAIT_MILLIS);
}
catch (IOException ignored)
{
// Best-effort cleanup: process.destroy() above is still applied.
}
finally
{
closeQuietly(reader);
if (netstat != null && netstat.isAlive())
{
netstat.destroyForcibly();
}
}
return new ArrayList<Integer>(pids);
}
private Integer parseWindowsListeningPid(String line, int port)
{
String trimmed = line == null ? "" : line.trim();
if (!trimmed.startsWith("TCP"))
{
return null;
}
String[] parts = trimmed.split("\\s+");
if (parts.length < 5 || !"LISTENING".equalsIgnoreCase(parts[3]) || !matchesLocalPort(parts[1], port))
{
return null;
}
try
{
return Integer.valueOf(parts[4]);
}
catch (NumberFormatException ignored)
{
return null;
}
}
private boolean matchesLocalPort(String localAddress, int port)
{
int separator = localAddress == null ? -1 : localAddress.lastIndexOf(':');
return separator >= 0 && String.valueOf(port).equals(localAddress.substring(separator + 1));
}
private boolean executeQuietly(List<String> command)
{
Process process = null;
try
{
process = new ProcessBuilder(command).redirectErrorStream(true).start();
if (!process.waitFor(GRACEFUL_WAIT_MILLIS, TimeUnit.MILLISECONDS))
{
return false;
}
return process.exitValue() == 0;
}
catch (IOException ignored)
{
// Missing OS tools should not break the stop API.
return false;
}
catch (InterruptedException e)
{
Thread.currentThread().interrupt();
return false;
}
finally
{
if (process != null && process.isAlive())
{
process.destroyForcibly();
}
}
}
private void waitForExit(Process process, long timeoutMillis)
{
try
{
process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS);
}
catch (InterruptedException e)
{
Thread.currentThread().interrupt();
}
}
private void closeQuietly(BufferedReader reader)
{
if (reader == null)
{
return;
}
try
{
reader.close();
}
catch (IOException ignored)
{
}
}
private boolean isWindows()
{
String osName = System.getProperty("os.name");
return osName != null && osName.toLowerCase().contains("win");
}
}

View File

@@ -0,0 +1,515 @@
package com.ruoyi.generator.service.front;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
public class RunPreviewProcessRunner
{
private static final String SPRING_BOOT_RUN_GOAL = "org.springframework.boot:spring-boot-maven-plugin:2.5.15:run";
private static final int VITE_MIN_NODE_MAJOR = 18;
private static final long NODE_VERSION_CHECK_TIMEOUT_SECONDS = 5L;
private static final Pattern NODE_VERSION_PATTERN = Pattern.compile("v?(\\d+)(?:\\.\\d+){0,2}");
private static final String WINDOWS_PROCESS_WRAPPER =
"[System.IO.File]::WriteAllText("
+ "$env:EASYCODE_PREVIEW_PID_FILE,"
+ "[string]$PID,"
+ "[System.Text.Encoding]::ASCII"
+ ");"
+ "& $env:ComSpec /d /s /c $env:EASYCODE_PREVIEW_COMMAND;"
+ "exit $LASTEXITCODE";
@Value("${easycode.preview.node-home:${EASYCODE_PREVIEW_NODE_HOME:}}")
private String nodeHome;
public Process startBackend(File workspace, Map<String, String> environment) throws IOException
{
return start("mvn spring-boot:run", workspace, environment);
}
public Process startBackend(File previewBuildDirectory, File rootPom, File reactorPom, String backendModulePath,
File localRepository, Map<String, String> environment) throws IOException
{
File localRepositoryParent = localRepository.getParentFile();
if (localRepositoryParent != null && !localRepositoryParent.mkdirs() && !localRepositoryParent.isDirectory())
{
throw new IOException("Unable to create preview Maven repository directory");
}
String repositoryOption = shellQuote("-Dmaven.repo.local=" + localRepository.getAbsolutePath());
String rootInstallCommand = "mvn " + repositoryOption + " -N -f " + shellQuote(rootPom.getAbsolutePath()) + " install";
String backendRunCommand = "mvn " + repositoryOption + " -f " + shellQuote(reactorPom.getName())
+ " -pl " + shellQuote(backendModulePath) + " -am -DskipTests " + SPRING_BOOT_RUN_GOAL;
return start(rootInstallCommand + " && " + backendRunCommand,
previewBuildDirectory, environment);
}
public Process startFrontend(File vueDirectory, Map<String, String> environment) throws IOException
{
return start("npm install && npm run dev", vueDirectory, prepareFrontendEnvironment(vueDirectory, environment));
}
private Process start(String shellCommand, File workingDirectory, Map<String, String> environment) throws IOException
{
if (isWindows())
{
return startWindowsManaged(shellCommand, workingDirectory, environment);
}
ProcessBuilder builder = new ProcessBuilder(shellCommand(shellCommand));
builder.directory(workingDirectory);
builder.redirectErrorStream(true);
if (environment != null)
{
builder.environment().putAll(environment);
}
return builder.start();
}
Process startCommandForTesting(String shellCommand, File workingDirectory,
Map<String, String> environment) throws IOException
{
return start(shellCommand, workingDirectory, environment);
}
private Process startWindowsManaged(String shellCommand, File workingDirectory,
Map<String, String> environment) throws IOException
{
File pidFile = new File(workingDirectory, ".easycode-process-" + UUID.randomUUID() + ".pid");
ProcessBuilder builder = new ProcessBuilder(
"powershell.exe",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
WINDOWS_PROCESS_WRAPPER);
builder.directory(workingDirectory);
builder.redirectErrorStream(true);
if (environment != null)
{
builder.environment().putAll(environment);
}
builder.environment().put("EASYCODE_PREVIEW_COMMAND", shellCommand);
builder.environment().put("EASYCODE_PREVIEW_PID_FILE", pidFile.getAbsolutePath());
return new ManagedPreviewProcess(builder.start(), pidFile);
}
Map<String, String> prepareFrontendEnvironment(File vueDirectory, Map<String, String> environment) throws IOException
{
Map<String, String> result = new HashMap<String, String>();
if (environment != null)
{
result.putAll(environment);
}
if (requiresModernNode(vueDirectory))
{
prepareModernNodeEnvironment(result);
}
else if (hasText(nodeHome))
{
prependNodeHome(result, normalizeNodeHome(nodeHome));
}
return result;
}
String pathEnvironmentKey()
{
String key = environmentKey(System.getenv());
if (key != null)
{
return key;
}
return isWindows() ? "Path" : "PATH";
}
void setNodeHomeForTesting(String nodeHome)
{
this.nodeHome = nodeHome;
}
private void prepareModernNodeEnvironment(Map<String, String> environment) throws IOException
{
File configuredNodeHome = normalizeNodeHome(nodeHome);
if (configuredNodeHome != null)
{
assertCompatibleNodeHome(configuredNodeHome);
prependNodeHome(environment, configuredNodeHome);
return;
}
NodeVersion currentVersion = readNodeVersion("node", environment);
if (currentVersion != null && currentVersion.isAtLeast(VITE_MIN_NODE_MAJOR))
{
return;
}
File detectedNodeHome = findCompatibleFnmNodeHome();
if (detectedNodeHome != null)
{
prependNodeHome(environment, detectedNodeHome);
return;
}
String actualVersion = currentVersion == null ? "not found" : currentVersion.display;
throw new IOException("Generated Vite frontend preview requires Node.js >= " + VITE_MIN_NODE_MAJOR
+ ", but current node is " + actualVersion
+ ". Install Node.js 18+ or set easycode.preview.node-home/EASYCODE_PREVIEW_NODE_HOME to the Node installation directory.");
}
private boolean requiresModernNode(File vueDirectory) throws IOException
{
File packageJsonFile = new File(vueDirectory, "package.json");
if (!packageJsonFile.isFile())
{
return false;
}
String content = new String(Files.readAllBytes(packageJsonFile.toPath()), StandardCharsets.UTF_8);
JSONObject packageJson = JSON.parseObject(content);
return hasDependency(packageJson.getJSONObject("dependencies"), "vite")
|| hasDependency(packageJson.getJSONObject("devDependencies"), "vite")
|| hasViteScript(packageJson.getJSONObject("scripts"));
}
private boolean hasDependency(JSONObject dependencies, String dependencyName)
{
return dependencies != null && dependencies.containsKey(dependencyName);
}
private boolean hasViteScript(JSONObject scripts)
{
if (scripts == null)
{
return false;
}
for (String key : scripts.keySet())
{
String command = scripts.getString(key);
if (command != null && command.contains("vite"))
{
return true;
}
}
return false;
}
private void assertCompatibleNodeHome(File candidate) throws IOException
{
File nodeExecutable = nodeExecutable(candidate);
if (nodeExecutable == null)
{
throw new IOException("Configured preview Node home does not contain a node executable: " + candidate.getAbsolutePath());
}
NodeVersion version = readNodeVersion(nodeExecutable.getAbsolutePath(), Collections.<String, String>emptyMap());
if (version == null || !version.isAtLeast(VITE_MIN_NODE_MAJOR))
{
String actualVersion = version == null ? "not found" : version.display;
throw new IOException("Generated Vite frontend preview requires Node.js >= " + VITE_MIN_NODE_MAJOR
+ ", but configured node is " + actualVersion + " at " + candidate.getAbsolutePath());
}
}
private File findCompatibleFnmNodeHome() throws IOException
{
String appData = System.getenv("APPDATA");
if (!hasText(appData))
{
return null;
}
File nodeVersionsDirectory = new File(new File(appData, "fnm"), "node-versions");
File[] versionDirectories = nodeVersionsDirectory.listFiles();
if (versionDirectories == null)
{
return null;
}
File bestHome = null;
NodeVersion bestVersion = null;
for (File versionDirectory : versionDirectories)
{
File candidateHome = fnmInstallationDirectory(versionDirectory);
if (candidateHome == null)
{
continue;
}
File executable = nodeExecutable(candidateHome);
if (executable == null)
{
continue;
}
NodeVersion version = readNodeVersion(executable.getAbsolutePath(), Collections.<String, String>emptyMap());
if (version != null && version.isAtLeast(VITE_MIN_NODE_MAJOR)
&& (bestVersion == null || version.compareTo(bestVersion) > 0))
{
bestHome = candidateHome;
bestVersion = version;
}
}
return bestHome;
}
private File fnmInstallationDirectory(File versionDirectory)
{
if (versionDirectory == null || !versionDirectory.isDirectory() || !versionDirectory.getName().startsWith("v"))
{
return null;
}
File installationDirectory = new File(versionDirectory, "installation");
return installationDirectory.isDirectory() ? installationDirectory : versionDirectory;
}
private NodeVersion readNodeVersion(String nodeCommand, Map<String, String> environment) throws IOException
{
ProcessBuilder builder = new ProcessBuilder(nodeCommand, "-v");
builder.redirectErrorStream(true);
if (environment != null)
{
builder.environment().putAll(environment);
}
Process process;
try
{
process = builder.start();
}
catch (IOException e)
{
return null;
}
try
{
if (!process.waitFor(NODE_VERSION_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS))
{
process.destroy();
return null;
}
}
catch (InterruptedException e)
{
Thread.currentThread().interrupt();
return null;
}
StringBuilder output = new StringBuilder();
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
try
{
String line;
while ((line = reader.readLine()) != null)
{
output.append(line).append('\n');
}
}
finally
{
reader.close();
}
if (process.exitValue() != 0)
{
return null;
}
return NodeVersion.parse(output.toString().trim());
}
private void prependNodeHome(Map<String, String> environment, File candidate)
{
if (candidate == null)
{
return;
}
String key = pathEnvironmentKey(environment);
String current = environment.get(key);
if (current == null)
{
current = environmentValue(System.getenv(), key);
}
String value = candidate.getAbsolutePath();
if (hasText(current))
{
value += File.pathSeparator + current;
}
environment.put(key, value);
}
private String pathEnvironmentKey(Map<String, String> environment)
{
String key = environmentKey(environment);
return key == null ? pathEnvironmentKey() : key;
}
private String environmentKey(Map<String, String> environment)
{
for (String key : environment.keySet())
{
if ("PATH".equalsIgnoreCase(key))
{
return key;
}
}
return null;
}
private String environmentValue(Map<String, String> environment, String targetKey)
{
String value = environment.get(targetKey);
if (value != null)
{
return value;
}
for (Map.Entry<String, String> entry : environment.entrySet())
{
if (entry.getKey().equalsIgnoreCase(targetKey))
{
return entry.getValue();
}
}
return null;
}
private File normalizeNodeHome(String value)
{
if (!hasText(value))
{
return null;
}
File candidate = new File(value.trim());
if (candidate.isFile())
{
return candidate.getParentFile();
}
return candidate;
}
private File nodeExecutable(File nodeHome)
{
String[] names = isWindows()
? new String[] { "node.exe", "node.cmd", "node.bat", "node" }
: new String[] { "node" };
for (String name : names)
{
File candidate = new File(nodeHome, name);
if (candidate.isFile())
{
return candidate;
}
}
return null;
}
private boolean hasText(String value)
{
return value != null && value.trim().length() > 0;
}
private List<String> shellCommand(String command)
{
List<String> result = new ArrayList<String>();
if (isWindows())
{
result.add("cmd");
result.add("/c");
}
else
{
result.add("sh");
result.add("-c");
}
result.add(command);
return result;
}
private String shellQuote(String value)
{
return "\"" + value.replace("\"", "\\\"") + "\"";
}
private boolean isWindows()
{
String osName = System.getProperty("os.name");
return osName != null && osName.toLowerCase().contains("win");
}
private static class NodeVersion
{
private final String display;
private final int major;
private final int minor;
private final int patch;
private NodeVersion(String display, int major, int minor, int patch)
{
this.display = display;
this.major = major;
this.minor = minor;
this.patch = patch;
}
private static NodeVersion parse(String output)
{
Matcher matcher = NODE_VERSION_PATTERN.matcher(output);
if (!matcher.find())
{
return null;
}
String version = matcher.group();
String[] parts = version.replace("v", "").split("\\.");
return new NodeVersion(version, parsePart(parts, 0), parsePart(parts, 1), parsePart(parts, 2));
}
private static int parsePart(String[] parts, int index)
{
if (parts.length <= index)
{
return 0;
}
try
{
return Integer.parseInt(parts[index]);
}
catch (NumberFormatException e)
{
return 0;
}
}
private boolean isAtLeast(int expectedMajor)
{
return major >= expectedMajor;
}
private int compareTo(NodeVersion other)
{
if (major != other.major)
{
return major - other.major;
}
if (minor != other.minor)
{
return minor - other.minor;
}
return patch - other.patch;
}
}
}

View File

@@ -0,0 +1,120 @@
package com.ruoyi.generator.service.front;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class RunPreviewProcessKillerTest
{
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void stopRequestsWholeTreeTerminationForManagedProcess() throws Exception
{
File pidFile = temporaryFolder.newFile("preview.pid");
Files.write(pidFile.toPath(), "4321".getBytes(StandardCharsets.UTF_8));
FakeProcess delegate = new FakeProcess();
ManagedPreviewProcess process = new ManagedPreviewProcess(delegate, pidFile);
RecordingProcessKiller killer = new RecordingProcessKiller();
killer.stop(process, null);
assertEquals(Long.valueOf(4321L), killer.killedPid);
assertTrue(delegate.destroyed);
assertFalse(pidFile.exists());
}
@Test
public void stopFallsBackWhenManagedProcessHasNoPid()
{
File missingPidFile = new File(temporaryFolder.getRoot(), "missing.pid");
FakeProcess delegate = new FakeProcess();
ManagedPreviewProcess process = new ManagedPreviewProcess(delegate, missingPidFile);
RecordingProcessKiller killer = new RecordingProcessKiller();
killer.stop(process, null);
assertNull(killer.killedPid);
assertTrue(delegate.destroyed);
}
private static class RecordingProcessKiller extends RunPreviewProcessKiller
{
private Long killedPid;
@Override
protected boolean killWindowsProcessTree(long pid)
{
killedPid = Long.valueOf(pid);
return true;
}
}
private static class FakeProcess extends Process
{
private boolean alive = true;
private boolean destroyed;
@Override
public OutputStream getOutputStream()
{
return new ByteArrayOutputStream();
}
@Override
public InputStream getInputStream()
{
return new ByteArrayInputStream(new byte[0]);
}
@Override
public InputStream getErrorStream()
{
return new ByteArrayInputStream(new byte[0]);
}
@Override
public int waitFor()
{
alive = false;
return 0;
}
@Override
public int exitValue()
{
if (alive)
{
throw new IllegalThreadStateException();
}
return 0;
}
@Override
public void destroy()
{
alive = false;
destroyed = true;
}
@Override
public boolean isAlive()
{
return alive;
}
}
}

View File

@@ -0,0 +1,107 @@
package com.ruoyi.generator.service.front;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
public class RunPreviewProcessRunnerTest
{
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void prepareFrontendEnvironmentPrependsConfiguredNodeHomeForViteProject() throws Exception
{
File projectDirectory = viteProject();
File nodeHome = fakeNodeHome("v20.20.2");
RunPreviewProcessRunner runner = new RunPreviewProcessRunner();
runner.setNodeHomeForTesting(nodeHome.getAbsolutePath());
Map<String, String> environment = runner.prepareFrontendEnvironment(projectDirectory, new HashMap<String, String>());
assertTrue(environment.get(runner.pathEnvironmentKey()).startsWith(nodeHome.getAbsolutePath()));
}
@Test
public void prepareFrontendEnvironmentRejectsOldConfiguredNodeForViteProject() throws Exception
{
File projectDirectory = viteProject();
File nodeHome = fakeNodeHome("v14.21.3");
RunPreviewProcessRunner runner = new RunPreviewProcessRunner();
runner.setNodeHomeForTesting(nodeHome.getAbsolutePath());
try
{
runner.prepareFrontendEnvironment(projectDirectory, new HashMap<String, String>());
fail("Expected Node version validation to reject old Node");
}
catch (IOException e)
{
assertTrue(e.getMessage().contains("requires Node.js >= 18"));
assertTrue(e.getMessage().contains("v14.21.3"));
}
}
@Test
public void startCommandRecordsManagedWindowsProcessPid() throws Exception
{
if (!isWindows())
{
return;
}
File projectDirectory = temporaryFolder.newFolder("managed-process");
RunPreviewProcessRunner runner = new RunPreviewProcessRunner();
Process process = runner.startCommandForTesting(
"ping 127.0.0.1 -n 3 > nul", projectDirectory, new HashMap<String, String>());
try
{
assertTrue(process instanceof ManagedPreviewProcess);
assertTrue(((ManagedPreviewProcess) process).waitForRootPid(2000L) > 0L);
}
finally
{
new RunPreviewProcessKiller().stop(process, null);
}
}
private File viteProject() throws IOException
{
File projectDirectory = temporaryFolder.newFolder("vite-project");
String packageJson = "{\n"
+ " \"scripts\": { \"dev\": \"vite --host 0.0.0.0\" },\n"
+ " \"dependencies\": { \"vite\": \"5.3.1\" }\n"
+ "}";
Files.write(new File(projectDirectory, "package.json").toPath(), packageJson.getBytes(StandardCharsets.UTF_8));
return projectDirectory;
}
private File fakeNodeHome(String version) throws IOException
{
File nodeHome = temporaryFolder.newFolder("node-" + version.substring(1));
File nodeCommand = new File(nodeHome, isWindows() ? "node.cmd" : "node");
String script = isWindows()
? "@echo off\r\necho " + version + "\r\n"
: "#!/bin/sh\nprintf '%s\\n' '" + version + "'\n";
Files.write(nodeCommand.toPath(), script.getBytes(StandardCharsets.UTF_8));
nodeCommand.setExecutable(true);
return nodeHome;
}
private boolean isWindows()
{
String osName = System.getProperty("os.name");
return osName != null && osName.toLowerCase().contains("win");
}
}