feat: add AI test process and settings foundation
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
+42
@@ -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));
|
||||
}
|
||||
}
|
||||
+64
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user