diff --git a/src/main/java/com/example/workutils/biz/aitest/config/AiTestSettings.java b/src/main/java/com/example/workutils/biz/aitest/config/AiTestSettings.java new file mode 100644 index 0000000..05e0d51 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/config/AiTestSettings.java @@ -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)); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/config/AiTestSettingsService.java b/src/main/java/com/example/workutils/biz/aitest/config/AiTestSettingsService.java new file mode 100644 index 0000000..28d56fb --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/config/AiTestSettingsService.java @@ -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); + } + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/config/TestSpecificationService.java b/src/main/java/com/example/workutils/biz/aitest/config/TestSpecificationService.java new file mode 100644 index 0000000..22edc0f --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/config/TestSpecificationService.java @@ -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); + } + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/process/CommandResult.java b/src/main/java/com/example/workutils/biz/aitest/process/CommandResult.java new file mode 100644 index 0000000..ef38cdc --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/process/CommandResult.java @@ -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; + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/process/CommandSpec.java b/src/main/java/com/example/workutils/biz/aitest/process/CommandSpec.java new file mode 100644 index 0000000..698fe27 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/process/CommandSpec.java @@ -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 command, + Map 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); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/process/DefaultProcessExecutor.java b/src/main/java/com/example/workutils/biz/aitest/process/DefaultProcessExecutor.java new file mode 100644 index 0000000..4d88c32 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/process/DefaultProcessExecutor.java @@ -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 active = new AtomicReference<>(); + private final AtomicBoolean cancelRequested = new AtomicBoolean(); + + @Override + public CommandResult execute(CommandSpec spec, Consumer 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 stdout = readAsync(process.getInputStream(), output); + CompletableFuture 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 readAsync(InputStream input, Consumer output) { + return CompletableFuture.supplyAsync(() -> { + List 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(); + } + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/process/ProcessExecutor.java b/src/main/java/com/example/workutils/biz/aitest/process/ProcessExecutor.java new file mode 100644 index 0000000..828d8b7 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/process/ProcessExecutor.java @@ -0,0 +1,9 @@ +package com.example.workutils.biz.aitest.process; + +import java.util.function.Consumer; + +public interface ProcessExecutor { + CommandResult execute(CommandSpec spec, Consumer output); + + void cancelActive(); +} diff --git a/src/test/java/com/example/workutils/biz/aitest/config/AiTestSettingsServiceTest.java b/src/test/java/com/example/workutils/biz/aitest/config/AiTestSettingsServiceTest.java new file mode 100644 index 0000000..5f3e74c --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/config/AiTestSettingsServiceTest.java @@ -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()); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/config/TestSpecificationServiceTest.java b/src/test/java/com/example/workutils/biz/aitest/config/TestSpecificationServiceTest.java new file mode 100644 index 0000000..742dd14 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/config/TestSpecificationServiceTest.java @@ -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)); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/process/DefaultProcessExecutorTest.java b/src/test/java/com/example/workutils/biz/aitest/process/DefaultProcessExecutorTest.java new file mode 100644 index 0000000..f44e8ae --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/process/DefaultProcessExecutorTest.java @@ -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 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 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")); + } + } +}