feat: add AI test process and settings foundation

This commit is contained in:
张家训
2026-08-04 16:35:25 +08:00
parent e06a90b666
commit fc262da350
10 changed files with 513 additions and 0 deletions
@@ -0,0 +1,29 @@
package com.example.workutils.biz.aitest.config;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Objects;
public record AiTestSettings(Path jdk8Home, Path mavenHome, String codeFreeCommand,
int coverageTarget, int maxRepairRounds,
Duration codeFreeTimeout, Duration mavenTimeout) {
public AiTestSettings {
Objects.requireNonNull(jdk8Home, "jdk8Home");
Objects.requireNonNull(mavenHome, "mavenHome");
Objects.requireNonNull(codeFreeCommand, "codeFreeCommand");
Objects.requireNonNull(codeFreeTimeout, "codeFreeTimeout");
Objects.requireNonNull(mavenTimeout, "mavenTimeout");
if (coverageTarget < 0 || coverageTarget > 100) {
throw new IllegalArgumentException("coverageTarget must be between 0 and 100");
}
if (maxRepairRounds < 0 || maxRepairRounds > 10) {
throw new IllegalArgumentException("maxRepairRounds must be between 0 and 10");
}
}
public static AiTestSettings defaults() {
return new AiTestSettings(Path.of(""), Path.of(""), "codefree",
85, 3, Duration.ofMinutes(5), Duration.ofMinutes(15));
}
}
@@ -0,0 +1,93 @@
package com.example.workutils.biz.aitest.config;
import com.example.workutils.system.config.WorkUtilsProperties;
import com.example.workutils.system.support.ApplicationPaths;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.Properties;
@Service
public class AiTestSettingsService {
private final Path settingsFile;
@Autowired
public AiTestSettingsService(WorkUtilsProperties properties) {
this(properties.getWorkutilsConfigFilePath().getParent().resolve("ai-test"));
}
public AiTestSettingsService(ApplicationPaths paths) {
this(paths.resolve("data", "ai-test"));
}
private AiTestSettingsService(Path aiTestDataDirectory) {
this.settingsFile = aiTestDataDirectory.resolve("settings.properties");
}
public AiTestSettings load() throws IOException {
if (Files.notExists(settingsFile)) {
return AiTestSettings.defaults();
}
Properties values = new Properties();
try (InputStream input = Files.newInputStream(settingsFile)) {
values.load(input);
}
AiTestSettings defaults = AiTestSettings.defaults();
return new AiTestSettings(
Path.of(values.getProperty("jdk8.home", defaults.jdk8Home().toString())),
Path.of(values.getProperty("maven.home", defaults.mavenHome().toString())),
values.getProperty("codefree.command", defaults.codeFreeCommand()),
integer(values, "coverage.target", defaults.coverageTarget()),
integer(values, "repair.rounds", defaults.maxRepairRounds()),
Duration.ofSeconds(integer(values, "codefree.timeout.seconds",
Math.toIntExact(defaults.codeFreeTimeout().toSeconds()))),
Duration.ofSeconds(integer(values, "maven.timeout.seconds",
Math.toIntExact(defaults.mavenTimeout().toSeconds()))));
}
public void save(AiTestSettings settings) throws IOException {
Files.createDirectories(settingsFile.getParent());
Properties values = new Properties();
values.setProperty("jdk8.home", settings.jdk8Home().toString());
values.setProperty("maven.home", settings.mavenHome().toString());
values.setProperty("codefree.command", settings.codeFreeCommand());
values.setProperty("coverage.target", Integer.toString(settings.coverageTarget()));
values.setProperty("repair.rounds", Integer.toString(settings.maxRepairRounds()));
values.setProperty("codefree.timeout.seconds", Long.toString(settings.codeFreeTimeout().toSeconds()));
values.setProperty("maven.timeout.seconds", Long.toString(settings.mavenTimeout().toSeconds()));
Path temporary = Files.createTempFile(settingsFile.getParent(), "settings-", ".tmp");
try (OutputStream output = Files.newOutputStream(temporary)) {
values.store(output, "WorkUtils AI unit test settings");
}
moveAtomically(temporary, settingsFile);
}
public Path settingsFile() {
return settingsFile;
}
private static int integer(Properties values, String key, int fallback) {
try {
return Integer.parseInt(values.getProperty(key, Integer.toString(fallback)).trim());
} catch (NumberFormatException ignored) {
return fallback;
}
}
private static void moveAtomically(Path source, Path target) throws IOException {
try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException ignored) {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}
@@ -0,0 +1,92 @@
package com.example.workutils.biz.aitest.config;
import com.example.workutils.system.config.WorkUtilsProperties;
import com.example.workutils.system.support.ApplicationPaths;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
@Service
public class TestSpecificationService {
private static final String DEFAULT_SPEC = """
# AI 单测默认质量规范
- 使用 JUnit 5。
- 测试命名、断言和 Mockito 风格遵循项目现有测试。
- 每个测试验证一个明确的业务场景。
- 异常场景使用 assertThrows。
- 禁止 @Disabled、空断言、吞掉异常和反射测试 private 方法。
- 禁止 Mock 被测类的核心业务逻辑。
""";
private final Path dataDirectory;
@Autowired
public TestSpecificationService(WorkUtilsProperties properties) {
this(properties.getWorkutilsConfigFilePath().getParent().resolve("ai-test"));
}
public TestSpecificationService(ApplicationPaths paths) {
this(paths.resolve("data", "ai-test"));
}
private TestSpecificationService(Path dataDirectory) {
this.dataDirectory = dataDirectory;
}
public String loadDefault() throws IOException {
Path file = defaultSpecificationPath();
if (Files.notExists(file)) {
saveDefault(DEFAULT_SPEC);
}
return Files.readString(file, StandardCharsets.UTF_8);
}
public void saveDefault(String content) throws IOException {
write(defaultSpecificationPath(), content);
}
public void resetDefault() throws IOException {
saveDefault(DEFAULT_SPEC);
}
public String loadProject(Path project) throws IOException {
Path file = projectSpecificationPath(project);
return Files.exists(file) ? Files.readString(file, StandardCharsets.UTF_8) : "";
}
public void saveProject(Path project, String content) throws IOException {
write(projectSpecificationPath(project), content);
}
public Path defaultSpecificationPath() {
return dataDirectory.resolve("default-test-spec.md");
}
public Path projectSpecificationPath(Path project) {
String id = sha256(project.toAbsolutePath().normalize().toString());
return dataDirectory.resolve("projects").resolve(id).resolve("test-spec.md");
}
private static void write(Path file, String content) throws IOException {
Files.createDirectories(file.getParent());
Files.writeString(file, content == null ? "" : content, StandardCharsets.UTF_8);
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable", impossible);
}
}
}
@@ -0,0 +1,10 @@
package com.example.workutils.biz.aitest.process;
import java.time.Duration;
public record CommandResult(int exitCode, boolean timedOut, boolean cancelled,
String stdout, String stderr, Duration duration) {
public boolean successful() {
return exitCode == 0 && !timedOut && !cancelled;
}
}
@@ -0,0 +1,27 @@
package com.example.workutils.biz.aitest.process;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public record CommandSpec(Path workingDirectory, List<String> command,
Map<String, String> environment, Duration timeout) {
public CommandSpec {
Objects.requireNonNull(workingDirectory, "workingDirectory");
Objects.requireNonNull(command, "command");
Objects.requireNonNull(environment, "environment");
Objects.requireNonNull(timeout, "timeout");
if (command.isEmpty() || command.stream().anyMatch(Objects::isNull)) {
throw new IllegalArgumentException("command must not be empty");
}
if (timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException("timeout must be positive");
}
workingDirectory = workingDirectory.toAbsolutePath().normalize();
command = List.copyOf(command);
environment = Map.copyOf(environment);
}
}
@@ -0,0 +1,111 @@
package com.example.workutils.biz.aitest.process;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
@Component
public class DefaultProcessExecutor implements ProcessExecutor {
private final AtomicReference<Process> active = new AtomicReference<>();
private final AtomicBoolean cancelRequested = new AtomicBoolean();
@Override
public CommandResult execute(CommandSpec spec, Consumer<String> output) {
if (!Files.isDirectory(spec.workingDirectory())) {
throw new IllegalArgumentException("Working directory does not exist: " + spec.workingDirectory());
}
if (active.get() != null) {
throw new IllegalStateException("Another command is already running");
}
cancelRequested.set(false);
Instant started = Instant.now();
Process process = null;
try {
ProcessBuilder builder = new ProcessBuilder(spec.command());
builder.directory(spec.workingDirectory().toFile());
builder.environment().putAll(spec.environment());
process = builder.start();
if (!active.compareAndSet(null, process)) {
destroyTree(process);
throw new IllegalStateException("Another command is already running");
}
CompletableFuture<String> stdout = readAsync(process.getInputStream(), output);
CompletableFuture<String> stderr = readAsync(process.getErrorStream(), output);
boolean finished = process.waitFor(spec.timeout().toMillis(), TimeUnit.MILLISECONDS);
boolean timedOut = !finished && !cancelRequested.get();
if (!finished) {
destroyTree(process);
process.waitFor(5, TimeUnit.SECONDS);
}
int exitCode = process.isAlive() ? -1 : process.exitValue();
return new CommandResult(exitCode, timedOut, cancelRequested.get(),
stdout.join(), stderr.join(), Duration.between(started, Instant.now()));
} catch (IOException e) {
throw new IllegalStateException("Unable to start command: " + spec.command().get(0), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
if (process != null) {
destroyTree(process);
}
return new CommandResult(-1, false, true, "", "Interrupted",
Duration.between(started, Instant.now()));
} finally {
active.compareAndSet(process, null);
}
}
@Override
public void cancelActive() {
cancelRequested.set(true);
Process process = active.get();
if (process != null) {
destroyTree(process);
}
}
private static CompletableFuture<String> readAsync(InputStream input, Consumer<String> output) {
return CompletableFuture.supplyAsync(() -> {
List<String> lines = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(input, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
lines.add(line);
output.accept(line);
}
} catch (IOException e) {
lines.add("[stream read failed] " + e.getMessage());
}
return String.join(System.lineSeparator(), lines);
});
}
private static void destroyTree(Process process) {
process.descendants().forEach(child -> {
child.destroy();
if (child.isAlive()) {
child.destroyForcibly();
}
});
process.destroy();
if (process.isAlive()) {
process.destroyForcibly();
}
}
}
@@ -0,0 +1,9 @@
package com.example.workutils.biz.aitest.process;
import java.util.function.Consumer;
public interface ProcessExecutor {
CommandResult execute(CommandSpec spec, Consumer<String> output);
void cancelActive();
}
@@ -0,0 +1,36 @@
package com.example.workutils.biz.aitest.config;
import com.example.workutils.system.support.ApplicationPaths;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AiTestSettingsServiceTest {
@Test
void roundTripsGlobalSettings(@TempDir Path home) throws Exception {
AiTestSettingsService service = new AiTestSettingsService(ApplicationPaths.of(home));
AiTestSettings expected = new AiTestSettings(
home.resolve("jdk8"), home.resolve("maven"), "codefree",
85, 3, Duration.ofMinutes(5), Duration.ofMinutes(15));
service.save(expected);
assertEquals(expected, service.load());
}
@Test
void returnsSafeDefaultsWhenNoSettingsExist(@TempDir Path home) throws Exception {
AiTestSettings settings = new AiTestSettingsService(ApplicationPaths.of(home)).load();
assertEquals(85, settings.coverageTarget());
assertEquals(3, settings.maxRepairRounds());
assertEquals("codefree", settings.codeFreeCommand());
assertTrue(settings.jdk8Home().toString().isEmpty());
}
}
@@ -0,0 +1,42 @@
package com.example.workutils.biz.aitest.config;
import com.example.workutils.system.support.ApplicationPaths;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TestSpecificationServiceTest {
@Test
void createsEditableDefaultAndCanResetIt(@TempDir Path home) throws Exception {
TestSpecificationService service = new TestSpecificationService(ApplicationPaths.of(home));
String original = service.loadDefault();
assertTrue(original.contains("JUnit 5"));
service.saveDefault("团队规范");
assertEquals("团队规范", service.loadDefault());
service.resetDefault();
assertEquals(original, service.loadDefault());
}
@Test
void storesProjectSpecificationsOutsideTheBusinessRepository(@TempDir Path home) throws Exception {
TestSpecificationService service = new TestSpecificationService(ApplicationPaths.of(home));
Path project = home.resolve("business/order");
service.saveProject(project, "订单项目规范");
assertEquals("订单项目规范", service.loadProject(project));
assertTrue(Files.exists(home.resolve("data/ai-test/projects")));
assertTrue(Files.notExists(project.resolve(".workutils")));
assertNotEquals("", Files.readString(service.projectSpecificationPath(project), StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,64 @@
package com.example.workutils.biz.aitest.process;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class DefaultProcessExecutorTest {
@Test
void injectsEnvironmentAndStreamsUtf8Output(@TempDir Path directory) {
DefaultProcessExecutor executor = new DefaultProcessExecutor();
List<String> streamed = new ArrayList<>();
CommandResult result = executor.execute(new CommandSpec(
directory,
javaCommand("print"),
Map.of("AI_TEST_MARKER", "中文-ok"),
Duration.ofSeconds(10)), streamed::add);
assertTrue(result.successful());
assertEquals("中文-ok", result.stdout().trim());
assertTrue(streamed.stream().anyMatch(line -> line.contains("中文-ok")));
}
@Test
void timesOutAndTerminatesTheProcess(@TempDir Path directory) {
DefaultProcessExecutor executor = new DefaultProcessExecutor();
CommandResult result = executor.execute(new CommandSpec(
directory,
javaCommand("sleep"),
Map.of(),
Duration.ofMillis(100)), ignored -> { });
assertTrue(result.timedOut());
assertFalse(result.successful());
}
private static List<String> javaCommand(String action) {
String executable = Path.of(System.getProperty("java.home"), "bin",
isWindows() ? "java.exe" : "java").toString();
return List.of(executable, "-Dfile.encoding=UTF-8", "-cp", System.getProperty("java.class.path"),
EnvPrinter.class.getName(), action);
}
private static boolean isWindows() {
return System.getProperty("os.name", "").toLowerCase().contains("win");
}
public static final class EnvPrinter {
public static void main(String[] args) throws Exception {
if ("sleep".equals(args[0])) {
Thread.sleep(30_000);
return;
}
System.out.println(System.getenv("AI_TEST_MARKER"));
}
}
}