diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46842a9..f46cbbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,12 +4,19 @@ on: pull_request: push: branches: [main] + workflow_dispatch: + inputs: + sdk-revision: + description: 'Exact lithe-plugin-docs commit for development-only Java 8 validation' + required: true + type: string permissions: contents: read jobs: package: + if: github.event_name != 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 15 strategy: @@ -40,6 +47,15 @@ jobs: env: LITHE_TEST_JAVA: ${{ env.JAVA_HOME_8_X64 }}/bin/java run: node scripts/test-backend.mjs + - name: Test native Provider execution and cleanup (Java 17) + env: + LITHE_REPORT_DIR: .artifacts/test-stability/native-java17 + run: node scripts/test-native.mjs + - name: Test native Provider execution and cleanup (Java 8) + env: + LITHE_TEST_JAVA: ${{ env.JAVA_HOME_8_X64 }}/bin/java + LITHE_REPORT_DIR: .artifacts/test-stability/native-java8 + run: node scripts/test-native.mjs - name: Download Maven ${{ matrix.maven }} if: matrix.maven != 'runner' env: @@ -79,3 +95,75 @@ jobs: name: script-timings-maven-${{ matrix.maven }} path: .artifacts/test-stability/ if-no-files-found: warn + + development-sdk: + name: Development SDK / Maven ${{ matrix.maven }} / Java 8 + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + maven: [runner, 3.6.3] + steps: + - uses: actions/checkout@v4 + - name: Require a fixed SDK source revision + env: + SDK_REVISION: ${{ inputs.sdk-revision }} + run: '[[ "$SDK_REVISION" =~ ^[0-9a-f]{40}$ ]]' + - uses: actions/checkout@v4 + with: + repository: Lithe-IDEA/lithe-plugin-docs + ref: ${{ inputs.sdk-revision }} + path: .artifacts/sdk-source + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: | + 8 + 17 + - name: Build the explicit development SDK and isolated plugin package + run: | + mvn -B -ntp -q -f .artifacts/sdk-source/sdk/java/pom.xml -DskipTests package + node scripts/package-development.mjs --sdk-jar "$GITHUB_WORKSPACE/.artifacts/sdk-source/sdk/java/plugin-sdk/target/lithe-plugin-sdk-java-0.1.0.jar" + node --input-type=module -e 'import {readFileSync,appendFileSync} from "node:fs"; const p=JSON.parse(readFileSync(".artifacts/native-development/latest.json")); if(p.developmentOnly!==true) throw Error("Expected a development package"); appendFileSync(process.env.GITHUB_ENV, `LITHE_TEST_PACKAGE=${p.package}\n`);' + - name: Check process cleanup + run: node scripts/test-process.mjs + - name: Test snapshot and native execution on Java 8 + env: + LITHE_TEST_JAVA: ${{ env.JAVA_HOME_8_X64 }}/bin/java + run: | + node scripts/test-backend.mjs + node scripts/test-native.mjs + - name: Download Maven ${{ matrix.maven }} + if: matrix.maven != 'runner' + env: + VERSION: ${{ matrix.maven }} + run: | + base="https://archive.apache.org/dist/maven/maven-3/$VERSION/binaries/apache-maven-$VERSION-bin.tar.gz" + curl -fsSLo "$RUNNER_TEMP/maven.tar.gz" "$base" + echo "$(curl -fsSL "$base.sha512" | cut -d' ' -f1) $RUNNER_TEMP/maven.tar.gz" | sha512sum -c - + tar -xzf "$RUNNER_TEMP/maven.tar.gz" -C "$RUNNER_TEMP" + echo "MVN=$RUNNER_TEMP/apache-maven-$VERSION/bin/mvn" >> "$GITHUB_ENV" + - name: Run the real Maven reactor through the Java 8 Provider + env: + LITHE_TEST_JAVA: ${{ env.JAVA_HOME_8_X64 }}/bin/java + run: node scripts/test.mjs + - name: Prove the release inputs stayed unchanged + run: | + git diff --exit-code -- sdk.lock.json backend/pom.xml extension/pom.xml io.github.lithe-idea.maven-build + test ! -e dist + - name: Upload development timing and identity reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: development-sdk-maven-${{ matrix.maven }} + path: | + .artifacts/test-stability/ + .artifacts/native-development/latest.json + .artifacts/native-development/run-*/identity.json + if-no-files-found: warn diff --git a/README.md b/README.md index be9662b..727dfc4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 为 [Lithe](https://github.com/1lck/Lithe-IDEA) 提供 Maven 项目支持:识别 `pom.xml`,在 Maven 工具窗口里列出 模块、生命周期和插件目标,运行任务和测试,查看依赖树。 -这是一个**社区插件**,用 Lithe 的声明式构建系统接口(插件 API 3 的 `buildSystems`)实现, +这是一个**社区插件**,用 Lithe 的构建系统接口(插件 API 3 的 `buildSystems` 和 `buildProviders: 3`)实现, 除了下面说的 Maven 扩展 jar 和读取项目用的 Java 后端,没有任何宿主代码。 > English: Maven support for Lithe through the declarative `buildSystems` plugin API — project tree, lifecycle and @@ -56,11 +56,60 @@ brew install maven 指纹不变时,重新打开项目或展开模块直接用快照,不启动 Maven。 - 同时到达的请求共用一次 Maven 运行;全部请求都取消后才结束 Maven。运行期间 POM 被修改时,本次结果照常显示但不保存。 - 指纹看不到的变化(例如根目录外的父 POM、远程仓库里更新的 SNAPSHOT)请点 Reload,它总是重新运行 Maven。 -- 不会编译或打包,也不会生成 `target/`;解析依赖时只读 POM,不下载依赖 jar。 +- 读取模型和依赖不会编译或打包,也不会生成 `target/`;只有用户显式执行任务或测试才运行构建。解析依赖时只读 POM,不下载依赖 jar。 - 没有 Lithe 的属性时扩展什么都不做;插件目录在运行时保持只读。 读取项目会执行 POM 中声明的 Maven 扩展和插件,首次读取还可能联网下载插件描述,所以 Lithe 会先确认你信任这个工作区。 +## 原生任务与测试(开发中) + +当前源码把生命周期、模型中的插件目标、自定义目标以及测试统一交给同一个工作区 +Maven 后端执行。清单里这些入口的命令模板已删除,宿主仅传任务 ID、工程选择、 +生效选项和原始参数数组。Maven 自己读取工程并执行;插件没有重写 POM、工程模型、 +依赖求解或 Surefire。普通任务的 Skip Tests 仍按任务语义生效;显式测试动作不会 +被这个工具栏开关跳过。 + +嵌套工作区使用宿主提供的工作区相对路径,执行前按当前 Maven 扩展模型重新确认 +模块名称、目录及插件目标,插件目标不能借一个伪造 properties.goal 运行。输出通过 +Provider 进度传回,最终结果保留非零退出码;取消或宿主退出会结束本次 Maven 与子进程。 +总输出最多 8 MiB,超限会明确失败并停止工具。报告和编译输出仍写用户工程的 target; +快照写宿主提供的工作区状态目录,插件安装目录保持只读。 + +测试的类和方法选择映射到 Surefire 的公开接口,特殊模式明确拒绝,不把一个精确 +请求扩大成通配匹配。使用 -am 构建依赖模块时,它们可以没有所选测试;执行结束后 +还检查当前模块/反应堆是否产生所选测试的新报告,避免“不存在的方法 + 成功空结果” +或旧报告被当成测试通过。完整结果仍由宿主既有 JUnit XML 解析器展示。 + +插件通过 buildModelInputs 1 显式公开规范化项目、目标、内容目录及 profiles/offline/ +skipTests 的生效值;共享内容不包含任务、运行计划、settings.xml 内容或私有函数。 +这为后续 Java 协作提供数据入口,不表示 Java/JDT、旧 MavenService、Agent 或设置 +已经完成迁移。 + +**发布前依赖尚未完成:** 该源码需要包含原生执行处理器的新版 Java SDK,正式 +sdk.lock.json 仍锁定已有发布物,没有伪造新版本/校验值或放松 SHA-256 检查。 +必须先发布配套 SDK 并更新锁定值,之后才可走正式 package.mjs 和 Release 流程。 +当前只使用显式的本地开发构建验证,不应把它作为可发布包。 + +```bash +node scripts/package-development.mjs --sdk-jar /absolute/path/to/development-sdk.jar +# 上一步打印独立目录,或读取 .artifacts/native-development/latest.json 的 package。 +export LITHE_TEST_PACKAGE=/absolute/path/printed/above/io.github.lithe-idea.maven-build +node scripts/test-backend.mjs +node scripts/test-native.mjs +node scripts/test.mjs +# 可选:在包含 buildModelInputs 的当前宿主 Core 上完整验证计划、报告及模型共享。 +LITHE_CORE_LIBRARY=/absolute/path/to/liblithe_core.dylib node scripts/test-native-core.mjs +``` + +开发构建在 .artifacts/native-development/run-* 的独立源码副本中编译,记录显式 SDK +的 SHA-256 和完整包文件清单;不生成发行 ZIP、不写 dist/,不改 sdk.lock.json。 +这些工作区产物不能跨工作树共享,不是产品运行缓存。测试保留每项 JSON/HTML/JUnit +耗时并清理所有本次进程。真实 Core 验证还需要 Python 3 的标准 ctypes 模块,不会安装 +Python 包。Windows 产品尚未接入本契约,本地 Java 21 验证也不能代替 Java 8 实机验收。 + +公开接口依据:[Maven CLI](https://maven.apache.org/ref/3-LATEST/maven-embedder/cli.html)、 +[Surefire 的测试选择](https://maven.apache.org/surefire/maven-surefire-plugin/test-mojo)。 + ## 安装 Lithe Store 的 Maven 条目合并后,才可在上述兼容开发版中打开 **设置 → 插件 → 插件市场**,搜索 “Maven”,安装并按提示重启。 @@ -143,6 +192,13 @@ node scripts/verify-scripts.mjs --host-bundle /Applications/Lithe.app ## 发布新版本 +合并前验证新版 SDK 源码时,可以手动运行 CI 的 `workflow_dispatch`,传入 +`sdk-revision`(`lithe-plugin-docs` 的完整提交 SHA)。该模式只构建隔离的开发包, +在 Java 8 上执行快照、原生任务/取消/清理与真实 Maven 反应堆测试,覆盖当前 Maven +和最低支持的 3.6.3。报告包含 SDK JAR 哈希和包文件清单;不会修改 `sdk.lock.json`、 +生成 `dist/` 或发布工件。普通 PR、主分支和 Release 继续使用正式 SDK 的地址与哈希 +校验,开发验证通过不能代替正式打包。 + 1. 同时修改 `plugin.json`、`extension/pom.xml` 和 `backend/pom.xml` 的版本(例如 `0.2.0`;打包脚本会检查三者一致), 确认 `sdk.lock.json` 已填入正式发布的 SDK 地址和 SHA-256,合入 `main`。 2. 推送同名标签: diff --git a/backend/src/main/java/io/github/litheidea/maven/backend/MavenBackend.java b/backend/src/main/java/io/github/litheidea/maven/backend/MavenBackend.java index 353a817..12ed122 100644 --- a/backend/src/main/java/io/github/litheidea/maven/backend/MavenBackend.java +++ b/backend/src/main/java/io/github/litheidea/maven/backend/MavenBackend.java @@ -11,10 +11,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Collections; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; /** @@ -61,9 +58,23 @@ private void serve() { }); backend.onModel((params, context) -> copy(load(params, context).model())); backend.onDependencies(this::dependencies); + backend.onExecute((params, context) -> execution(params, context, false)); + backend.onTest((params, context) -> execution(params, context, true)); backend.listen(); } + private Object execution(Map params, CallContext context, boolean tests) throws Exception { + Path root = root(params); + Map environment = environment(params.get("environment")); + Object tool = params.get("tool"); + String executable = tool instanceof Map ? Json.string(Json.object(tool), "executable") : null; + Path maven = MavenExecutable.resolve(executable, root, environment); + // Model and execution use the same effective options and Maven runtime. + // The extension remains the owner of reactor and plugin-goal facts. + MavenRuns.Loaded loaded = load(params, context); + return MavenExecution.run(params, context, root, workspaceRoot, maven, environment, loaded, tests); + } + private Object dependencies(Map params, CallContext context) throws Exception { Path root = root(params); MavenRuns.Loaded loaded = load(params, context); @@ -76,7 +87,7 @@ private Object dependencies(Map params, CallContext context) thr throw new PluginError("maven.invalidProject", "The selected project has no path."); } Map result = new LinkedHashMap<>(); - result.put("dependencies", Projects.dependenciesOf(loaded, root, path)); + result.put("dependencies", Projects.dependenciesOf(loaded, workspaceRoot, path)); return result; } @@ -92,16 +103,21 @@ private MavenRuns.Loaded load(Map params, CallContext context) t Object tool = params.get("tool"); String executable = tool instanceof Map ? Json.string(Json.object(tool), "executable") : null; Path maven = MavenExecutable.resolve(executable, root, environment); - Inputs inputs = new Inputs(root, maven, strings(params.get("optionArguments")), + Inputs inputs = new Inputs(root, maven, MavenExecution.optionArguments(params, false), environment, hostEnvironment(params.get("environment")), extensionJar()); boolean refresh = Boolean.TRUE.equals(params.get("refresh")); return runs.load(inputs, refresh, context.cancellation()); } - private Path root(Map params) { - String root = Json.string(params, "root"); - Path base = workspaceRoot == null ? Paths.get("").toAbsolutePath() : workspaceRoot; - return base.resolve(root == null || root.isEmpty() ? "." : root).toAbsolutePath().normalize(); + private Path root(Map params) throws IOException { + if (workspaceRoot == null) throw new PluginError("maven.notInitialized", "The Maven backend was not initialized."); + String value = Json.string(params, "root"); + if (value == null || value.isEmpty() || Paths.get(value).isAbsolute()) throw new PluginError("maven.invalidRoot", "Expected a workspace-relative Maven root."); + Path root = workspaceRoot.resolve(value).normalize().toRealPath(); + if (!root.startsWith(workspaceRoot) || !Files.isRegularFile(root.resolve("pom.xml"))) { + throw new PluginError("maven.invalidRoot", "The Maven root must contain pom.xml inside the workspace."); + } + return root; } /** @@ -130,19 +146,6 @@ private static Map hostEnvironment(Object fromHost) { return result; } - private static List strings(Object value) { - if (!(value instanceof List)) { - return Collections.emptyList(); - } - List result = new ArrayList<>(); - for (Object item : (List) value) { - if (item instanceof String) { - result.add((String) item); - } - } - return result; - } - /** The extension ships next to this JAR: {@code lib/lithe-maven-extension.jar}. */ private static Path extensionJar() { try { @@ -166,7 +169,7 @@ private static Path workspaceRoot(String rootUri) throws IOException { return Paths.get("").toAbsolutePath(); } // `Paths.get(URI)` accepts the trailing slash a directory URI may carry. - return Paths.get(URI.create(rootUri)).toAbsolutePath().normalize(); + return Paths.get(URI.create(rootUri)).toRealPath(); } /** A shallow copy: the SDK stamps {@code format} on the returned document. */ diff --git a/backend/src/main/java/io/github/litheidea/maven/backend/MavenExecution.java b/backend/src/main/java/io/github/litheidea/maven/backend/MavenExecution.java new file mode 100644 index 0000000..4f69c77 --- /dev/null +++ b/backend/src/main/java/io/github/litheidea/maven/backend/MavenExecution.java @@ -0,0 +1,205 @@ +package io.github.litheidea.maven.backend; + +import io.github.litheidea.plugin.sdk.CallContext; +import io.github.litheidea.plugin.sdk.Cancellation; +import io.github.litheidea.plugin.sdk.CancelledException; +import io.github.litheidea.plugin.sdk.Json; +import io.github.litheidea.plugin.sdk.PluginError; +import io.github.litheidea.plugin.sdk.Processes; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Native build/test requests use Maven's current reactor, never a host command template. */ +final class MavenExecution { + private static final List LIFECYCLE = Arrays.asList( + "clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"); + private static final List TEST_PHASES = Arrays.asList("test", "package", "verify", "install", "deploy", "plugin-goal", "goal"); + private static final int MAX_OUTPUT_BYTES = 8 * 1024 * 1024; + + private MavenExecution() {} + + static Map run(Map params, CallContext context, Path root, Path workspace, + Path maven, Map environment, MavenRuns.Loaded loaded, boolean tests) throws IOException { + String task = tests ? "test" : Json.string(params, "task"); + if (!tests && !LIFECYCLE.contains(task) && !"plugin-goal".equals(task) && !"goal".equals(task)) { + throw invalid("Unknown Maven task."); + } + Path project = selectedProject(params.get("project"), root, workspace, loaded); + if (params.get("target") != null) throw invalid("Maven tasks do not accept a target selector."); + List command = new ArrayList<>(); + command.add(maven.toString()); + command.add("-B"); + command.add("-ntp"); + command.addAll(optionArguments(params, !tests && TEST_PHASES.contains(task))); + if (project != null && !project.equals(root)) { + command.add("-pl"); + command.add(root.relativize(project).toString()); + } + MavenTestSelection selection = null; + if (tests) { + String suite = filter(params.get("suite"), false); + String method = filter(params.get("case"), true); + if (method != null && suite == null) throw invalid("A Maven test method needs its suite."); + if (suite != null) { + List owners = new ArrayList<>(); + if (project != null) owners.add(project); + else for (Object candidate : list(loaded.model().get("projects"))) { + String path = Json.string(Json.object(candidate), "path"); + if (path == null) continue; + Path owner = root.resolve(path).normalize().toRealPath(); + if (!owner.startsWith(root)) throw invalid("Maven report owner leaves the current reactor."); + if (!owners.contains(owner)) owners.add(owner); + } + selection = new MavenTestSelection(owners, suite, method); + if (project != null && !project.equals(root)) command.add("-am"); + command.add("-Dtest=" + suite + (method == null ? "" : "#" + method)); + command.add("-Dsurefire.failIfNoSpecifiedTests=false"); + } + command.add("test"); + } else if ("plugin-goal".equals(task)) { + command.add(pluginGoal(params.get("modelTask"), project, root, loaded)); + } else if (!"goal".equals(task)) { + if (params.get("modelTask") != null) throw invalid("Only a plugin-goal task accepts a model task."); + command.add(task); + } + List arguments = strings(params.get("userArguments")); + if ("goal".equals(task) && arguments.isEmpty()) throw invalid("A custom Maven goal needs arguments."); + command.addAll(arguments); + context.throwIfCancelled(); + Output output = new Output(context); + Processes.Result result; + try { + result = context.run(command, new Processes.Options().directory(root).environment(environment) + .cancellation(output.cancellation).maxOutputBytes(0).onStdout(output::append).onStderr(output::append)); + } catch (CancelledException error) { + if (output.overflow) throw new PluginError("maven.outputTooLarge", "Maven output exceeded the bounded build transcript."); + throw error; + } + context.throwIfCancelled(); + if (selection != null && result.exitCode() == 0) selection.verify(context); + Map reply = new LinkedHashMap<>(); + reply.put("exitCode", result.exitCode()); + reply.put("output", output.text()); + return reply; + } + + /** One configuration drives model import, task execution and test execution. */ + static List optionArguments(Map params, boolean skipTests) { + Map options = params.get("options") instanceof Map ? Json.object(params.get("options")) : Collections.emptyMap(); + List result = new ArrayList<>(); + Object profiles = options.get("profiles"); + if (profiles != null) { + List values = strings(profiles); + for (String value : values) if (value.contains(",") || value.startsWith("!") || value.startsWith("-")) throw invalid("Invalid active Maven profile."); + if (!values.isEmpty()) result.add("-P" + String.join(",", values)); + } + String settings = optionalString(options.get("settingsFile")); + if (settings != null && !settings.isEmpty()) { result.add("-s"); result.add(settings); } + String repository = optionalString(options.get("localRepository")); + if (repository != null && !repository.isEmpty()) result.add("-Dmaven.repo.local=" + repository); + if (booleanOption(options, "offline")) result.add("-o"); + if (skipTests && booleanOption(options, "skipTests")) result.add("-DskipTests"); + return result; + } + + private static Path selectedProject(Object selected, Path root, Path workspace, MavenRuns.Loaded loaded) throws IOException { + if (selected == null) return null; + if (!(selected instanceof Map)) throw invalid("Expected a Maven project selector."); + Map selection = Json.object(selected); + String path = Json.string(selection, "path"); + String name = Json.string(selection, "name"); + if (path == null || Paths.get(path).isAbsolute()) throw invalid("Expected a workspace-relative project path."); + Path wanted = workspace.resolve(path).normalize().toRealPath(); + if (!wanted.startsWith(root) || !Files.isDirectory(wanted)) throw invalid("The selected project leaves the Maven root."); + for (Object candidate : list(loaded.model().get("projects"))) { + Map project = Json.object(candidate); + String candidatePath = Json.string(project, "path"); + if (candidatePath != null && root.resolve(candidatePath).normalize().toRealPath().equals(wanted) + && name != null && name.equals(project.get("name"))) return wanted; + } + throw invalid("The selected project is absent from the current Maven reactor."); + } + + private static String pluginGoal(Object selected, Path project, Path root, MavenRuns.Loaded loaded) throws IOException { + if (!(selected instanceof Map) || project == null) throw invalid("A plugin goal needs its current model task and project."); + Map task = Json.object(selected); + String name = Json.string(task, "name"); + Object goal = Json.object(task.get("properties")).get("goal"); + for (Object candidate : list(loaded.model().get("tasks"))) { + Map known = Json.object(candidate); + String owner = Json.string(known, "project"); + if ("plugin-goal".equals(known.get("template")) && name != null && name.equals(known.get("name")) + && owner != null && root.resolve(owner).normalize().toRealPath().equals(project) + && goal instanceof String && goal.equals(Json.object(known.get("properties")).get("goal"))) return (String) goal; + } + throw invalid("The plugin goal is absent from the current Maven model."); + } + + private static String filter(Object value, boolean method) { + if (value == null) return null; + String text = optionalString(value); + // Surefire treats punctuation as patterns. Exact selectors must not widen to another test. + if (text == null || !text.matches(method ? "[A-Za-z_$][A-Za-z0-9_$]*" : "[A-Za-z_$][A-Za-z0-9_.$]*")) { + throw invalid("The test selector cannot be represented as an exact Surefire class or method."); + } + return text; + } + + private static boolean booleanOption(Map options, String name) { + Object value = options.get(name); + if (value != null && !(value instanceof Boolean)) throw invalid("Expected a boolean Maven option."); + return Boolean.TRUE.equals(value); + } + + private static String optionalString(Object value) { + if (value == null) return null; + if (!(value instanceof String) || ((String) value).length() > 4096 || ((String) value).chars().anyMatch(Character::isISOControl)) throw invalid("Invalid Maven string value."); + return (String) value; + } + + private static List strings(Object value) { + if (value == null) return Collections.emptyList(); + if (!(value instanceof List) || ((List) value).size() > 64) throw invalid("Expected bounded Maven arguments."); + List result = new ArrayList<>(); + for (Object item : (List) value) { + String text = optionalString(item); + if (text == null || text.isEmpty()) throw invalid("Empty Maven argument."); + result.add(text); + } + return result; + } + + private static List list(Object value) { return value instanceof List ? (List) value : Collections.emptyList(); } + private static PluginError invalid(String message) { return new PluginError("maven.invalidSelection", message); } + + /** Keeps progress and the final transcript in the same order, with one shared output budget. */ + private static final class Output { + private final CallContext context; + private final StringBuilder text = new StringBuilder(); + private int bytes; + private final Cancellation cancellation = new Cancellation(); + private volatile boolean overflow; + Output(CallContext context) { this.context = context; } + synchronized void append(String chunk) { + int size = chunk.getBytes(java.nio.charset.StandardCharsets.UTF_8).length; + if (overflow) return; + if (bytes + size > MAX_OUTPUT_BYTES) { + overflow = true; + cancellation.cancel("Maven output exceeded the build transcript budget."); + return; + } + bytes += size; + text.append(chunk); + context.progress(null, null, chunk); + } + synchronized String text() { return text.toString(); } + } +} diff --git a/backend/src/main/java/io/github/litheidea/maven/backend/MavenTestSelection.java b/backend/src/main/java/io/github/litheidea/maven/backend/MavenTestSelection.java new file mode 100644 index 0000000..f7f16b0 --- /dev/null +++ b/backend/src/main/java/io/github/litheidea/maven/backend/MavenTestSelection.java @@ -0,0 +1,115 @@ +package io.github.litheidea.maven.backend; + +import io.github.litheidea.plugin.sdk.CallContext; +import io.github.litheidea.plugin.sdk.PluginError; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.List; +import java.util.stream.Stream; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +/** Evidence that Surefire actually ran an explicit selector, not an empty successful reactor. */ +final class MavenTestSelection { + private static final int MAX_REPORT_BYTES = 8 * 1024 * 1024; + private static final int MAX_REPORTS = 10000; + private final Map before; + private final List roots; + private final String suite; + private final String method; + + MavenTestSelection(List roots, String suite, String method) throws IOException { + this.roots = roots; + this.suite = suite; + this.method = method; + before = reports(roots); + } + + void verify(CallContext context) throws IOException { + if (suite == null) return; + for (Map.Entry entry : reports(roots).entrySet()) { + context.throwIfCancelled(); + if (entry.getValue().same(before.get(entry.getKey()))) continue; + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + NodeList tests = factory.newDocumentBuilder().parse(new ByteArrayInputStream(entry.getValue().bytes)).getElementsByTagName("testcase"); + for (int index = 0; index < tests.getLength(); index++) { + Element test = (Element) tests.item(index); + String name = test.getAttribute("classname"); + if ((suite.equals(name) || (!suite.contains(".") && name.endsWith("." + suite))) && (method == null || method.equals(test.getAttribute("name")))) return; + } + } catch (ParserConfigurationException | SAXException | IllegalArgumentException error) { + throw new PluginError("maven.invalidTestReport", "Surefire produced an unreadable test report.", error); + } + } + throw new PluginError("maven.noMatchingTests", "Surefire produced no fresh result for the selected class or method."); + } + + private static Map reports(List roots) throws IOException { + Map result = new LinkedHashMap<>(); + long total = 0; + int visited = 0; + for (Path root : roots) { + Path directory = root.resolve("target/surefire-reports"); + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) continue; + Path real = directory.toRealPath(); + if (!real.startsWith(root)) throw new PluginError("maven.invalidTestReport", "Surefire reports leave the selected project."); + try (Stream files = Files.list(real)) { + for (Path file : (Iterable) files.limit(MAX_REPORTS + 1L).sorted()::iterator) { + if (++visited > MAX_REPORTS) throw new PluginError("maven.invalidTestReport", "Surefire reports exceed the verification budget."); + String name = file.getFileName().toString(); + if (!name.startsWith("TEST-") || !name.endsWith(".xml") || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) continue; + BasicFileAttributes attributes = Files.readAttributes(file, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (result.size() >= MAX_REPORTS || attributes.size() > MAX_REPORT_BYTES - total) throw new PluginError("maven.invalidTestReport", "Surefire reports exceed the verification budget."); + byte[] bytes = boundedRead(file, (int) (MAX_REPORT_BYTES - total)); + total += bytes.length; + result.put(file, new Stamp(attributes, bytes)); + } + } + } + return result; + } + + private static byte[] boundedRead(Path file, int remaining) throws IOException { + try (InputStream input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] chunk = new byte[8192]; + int count; + while ((count = input.read(chunk)) >= 0) { + if (count > remaining - output.size()) throw new PluginError("maven.invalidTestReport", "Surefire reports exceed the verification budget."); + output.write(chunk, 0, count); + } + return output.toByteArray(); + } + } + + private static final class Stamp { + final BasicFileAttributes attributes; + final byte[] bytes; + Stamp(BasicFileAttributes attributes, byte[] bytes) { this.attributes = attributes; this.bytes = bytes; } + boolean same(Stamp other) { + return other != null && attributes.lastModifiedTime().equals(other.attributes.lastModifiedTime()) + && java.util.Objects.equals(attributes.fileKey(), other.attributes.fileKey()) && Arrays.equals(bytes, other.bytes); + } + } +} diff --git a/backend/src/main/java/io/github/litheidea/maven/backend/Projects.java b/backend/src/main/java/io/github/litheidea/maven/backend/Projects.java index 37464f3..2f24999 100644 --- a/backend/src/main/java/io/github/litheidea/maven/backend/Projects.java +++ b/backend/src/main/java/io/github/litheidea/maven/backend/Projects.java @@ -19,14 +19,14 @@ final class Projects { private Projects() {} - static List dependenciesOf(MavenRuns.Loaded loaded, Path root, String path) { - Path wanted = root.resolve(path).toAbsolutePath().normalize(); + static List dependenciesOf(MavenRuns.Loaded loaded, Path workspace, String path) { + Path wanted = workspace.resolve(path).toAbsolutePath().normalize(); List projects = list(loaded.model().get("projects")); List nodes = list(loaded.dependencies().get("dependencies")); for (int index = 0; index < projects.size(); index++) { Map project = Json.object(projects.get(index)); String projectPath = Json.string(project, "path"); - if (projectPath == null || !samePath(wanted, root.resolve(projectPath))) { + if (projectPath == null || !samePath(wanted, workspace.resolve(projectPath))) { continue; } Map properties = Json.object(project.get("properties")); diff --git a/io.github.lithe-idea.maven-build/plugin.json b/io.github.lithe-idea.maven-build/plugin.json index d908d0e..02f4bd2 100644 --- a/io.github.lithe-idea.maven-build/plugin.json +++ b/io.github.lithe-idea.maven-build/plugin.json @@ -4,7 +4,7 @@ "displayName": "Maven", "version": "0.1.1", "pluginAPIVersion": 3, - "requires": { "buildProviders": 1, "buildSystems": 1, "serverProcess": 1 }, + "requires": { "buildProviders": 3, "buildModelInputs": 1, "buildSystems": 1, "serverProcess": 1 }, "vendor": { "id": "io.github.lithe-idea", "displayName": "Lithe Community", @@ -42,14 +42,7 @@ ], "environmentInputs": ["MAVEN_HOME"], "runtimeDependencies": [{ "id": "java", "minimumVersion": "8" }], - "globalArguments": [ - "-B", - "-ntp", - "${optionArguments:profiles}", - "${optionArguments:settingsFile}", - "${optionArguments:localRepository}", - "${optionArguments:offline}" - ], + "globalArguments": [], "version": { "arguments": ["--version"], "pattern": "Apache Maven (\\d+\\.\\d+\\.\\d+)" }, "minimumVersion": "3.6.3" }, @@ -99,6 +92,7 @@ "mayUseNetwork": true }, "model": { "source": "provider" }, + "modelSharing": { "options": ["profiles", "offline", "skipTests"] }, "tasks": [ { "id": "clean", @@ -106,11 +100,8 @@ "group": "lifecycle", "category": ["Lifecycle"], "scopes": ["workspace", "project"], - "arguments": [ - { "value": "-pl", "when": "nestedProjectSelected" }, - { "value": "${project.path}", "when": "nestedProjectSelected" }, - "clean" - ] + "source": "provider", + "arguments": [] }, { "id": "validate", @@ -118,11 +109,8 @@ "group": "lifecycle", "category": ["Lifecycle"], "scopes": ["workspace", "project"], - "arguments": [ - { "value": "-pl", "when": "nestedProjectSelected" }, - { "value": "${project.path}", "when": "nestedProjectSelected" }, - "validate" - ] + "source": "provider", + "arguments": [] }, { "id": "compile", @@ -130,11 +118,8 @@ "group": "lifecycle", "category": ["Lifecycle"], "scopes": ["workspace", "project"], - "arguments": [ - { "value": "-pl", "when": "nestedProjectSelected" }, - { "value": "${project.path}", "when": "nestedProjectSelected" }, - "compile" - ] + "source": "provider", + "arguments": [] }, { "id": "test", @@ -142,12 +127,8 @@ "group": "lifecycle", "category": ["Lifecycle"], "scopes": ["workspace", "project"], - "arguments": [ - { "value": "-pl", "when": "nestedProjectSelected" }, - { "value": "${project.path}", "when": "nestedProjectSelected" }, - "${optionArguments:skipTests}", - "test" - ] + "source": "provider", + "arguments": [] }, { "id": "package", @@ -155,12 +136,8 @@ "group": "lifecycle", "category": ["Lifecycle"], "scopes": ["workspace", "project"], - "arguments": [ - { "value": "-pl", "when": "nestedProjectSelected" }, - { "value": "${project.path}", "when": "nestedProjectSelected" }, - "${optionArguments:skipTests}", - "package" - ] + "source": "provider", + "arguments": [] }, { "id": "verify", @@ -168,12 +145,8 @@ "group": "lifecycle", "category": ["Lifecycle"], "scopes": ["workspace", "project"], - "arguments": [ - { "value": "-pl", "when": "nestedProjectSelected" }, - { "value": "${project.path}", "when": "nestedProjectSelected" }, - "${optionArguments:skipTests}", - "verify" - ] + "source": "provider", + "arguments": [] }, { "id": "install", @@ -181,12 +154,8 @@ "group": "lifecycle", "category": ["Lifecycle"], "scopes": ["workspace", "project"], - "arguments": [ - { "value": "-pl", "when": "nestedProjectSelected" }, - { "value": "${project.path}", "when": "nestedProjectSelected" }, - "${optionArguments:skipTests}", - "install" - ] + "source": "provider", + "arguments": [] }, { "id": "site", @@ -194,11 +163,8 @@ "group": "lifecycle", "category": ["Lifecycle"], "scopes": ["workspace", "project"], - "arguments": [ - { "value": "-pl", "when": "nestedProjectSelected" }, - { "value": "${project.path}", "when": "nestedProjectSelected" }, - "site" - ] + "source": "provider", + "arguments": [] }, { "id": "deploy", @@ -206,12 +172,8 @@ "group": "lifecycle", "category": ["Lifecycle"], "scopes": ["workspace", "project"], - "arguments": [ - { "value": "-pl", "when": "nestedProjectSelected" }, - { "value": "${project.path}", "when": "nestedProjectSelected" }, - "${optionArguments:skipTests}", - "deploy" - ] + "source": "provider", + "arguments": [] }, { "id": "plugin-goal", @@ -219,12 +181,8 @@ "group": "build", "category": ["Plugins"], "fromModel": true, - "arguments": [ - { "value": "-pl", "when": "nestedProjectSelected" }, - { "value": "${project.path}", "when": "nestedProjectSelected" }, - "${optionArguments:skipTests}", - "${task.property:goal}" - ] + "source": "provider", + "arguments": [] }, { "id": "goal", @@ -232,26 +190,15 @@ "group": "other", "scopes": ["workspace", "project"], "requiresUserArguments": true, - "arguments": [ - { "value": "-pl", "when": "nestedProjectSelected" }, - { "value": "${project.path}", "when": "nestedProjectSelected" }, - "${optionArguments:skipTests}", - "${userArguments}" - ] + "source": "provider", + "arguments": [] } ], "test": { "scopes": ["workspace", "project"], "filters": ["suite", "case"], - "arguments": [ - { "value": "-pl", "when": "nestedProjectSelected" }, - { "value": "${project.path}", "when": "nestedProjectSelected" }, - { "value": "-am", "when": ["nestedProjectSelected", "suiteSelected"] }, - { "value": "-Dtest=${test.suite}", "when": ["suiteSelected", "noCaseSelected"] }, - { "value": "-Dtest=${test.suite}#${test.case}", "when": ["suiteSelected", "caseSelected"] }, - { "value": "-Dsurefire.failIfNoSpecifiedTests=false", "when": "suiteSelected" }, - "test" - ], + "source": "provider", + "arguments": [], "results": { "decoder": "junitXml", "reports": ["target/surefire-reports/TEST-*.xml", "target/failsafe-reports/TEST-*.xml"] diff --git a/scripts/backend-client.mjs b/scripts/backend-client.mjs index 139c47f..7da58ac 100644 --- a/scripts/backend-client.mjs +++ b/scripts/backend-client.mjs @@ -2,7 +2,7 @@ // frames over the process's stdio, `initialize` offering litheBuild 1, then // lithe/build/* requests. Every wait is bounded, and the backend runs in its // own process group so a test can always end whatever it left behind. -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { pathToFileURL } from "node:url"; export const WAIT_MS = 20_000; @@ -12,6 +12,8 @@ export class BackendClient { #pending = new Map(); #nextId = 1; stderr = ""; + notifications = []; + onNotification; /** Starts `java -jar --state `; `javaOptions` go before `-jar`. */ constructor({ java = "java", jar, state, env = process.env, javaOptions = [], timeoutMs = WAIT_MS }) { @@ -66,11 +68,11 @@ export class BackendClient { this.notify("$/cancelRequest", { id }); } - async initialize(root) { + async initialize(root, versions = [1]) { const result = await this.request("initialize", { processId: process.pid, rootUri: pathToFileURL(root).href, - capabilities: { experimental: { litheBuild: { versions: [1] } } }, + capabilities: { experimental: { litheBuild: { versions } } }, }); this.notify("initialized", {}); return result; @@ -88,10 +90,27 @@ export class BackendClient { /** Ends the backend's whole process group, for teardown after a failure. */ kill() { - if (!this.child.pid) return; - // macOS answers EPERM for a group whose members already exited but are not reaped yet. - try { process.kill(-this.child.pid, "SIGKILL"); } - catch (error) { if (error.code !== "ESRCH" && error.code !== "EPERM") throw error; } + if (!this.child.pid || this.child.exitCode !== null || this.child.signalCode !== null) return; + // SDK tools form their own POSIX groups. Capture the live descendant tree + // before ending Java so an assertion failure also reaches those groups. + const snapshot = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8", timeout: 2000 }); + if (snapshot.error || snapshot.status !== 0) { + // Always try to stop the owner, then report incomplete cleanup evidence. + try { process.kill(-this.child.pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") throw error; } + throw new Error("Cannot inspect the backend process tree: " + (snapshot.error?.message ?? snapshot.stderr)); + } + const pairs = snapshot.stdout.trim().split("\n").map(line => line.trim().split(/\s+/).map(Number)); + const owned = new Set([this.child.pid]); + for (let count = 0; count < pairs.length; count++) { + const before = owned.size; + for (const [pid, parent] of pairs) if (owned.has(parent)) owned.add(pid); + if (before === owned.size) break; + } + const signal = (pid) => { + try { process.kill(pid, "SIGKILL"); } + catch (error) { if (error.code !== "ESRCH" && error.code !== "EPERM") throw error; } + }; + for (const pid of [...owned].reverse()) { signal(-pid); signal(pid); } } #write(message) { @@ -118,6 +137,10 @@ export class BackendClient { try { message = JSON.parse(this.#buffer.subarray(headerEnd + 4, headerEnd + 4 + length).toString("utf8")); } catch { return this.#broken("a frame that is not JSON"); } this.#buffer = this.#buffer.subarray(headerEnd + 4 + length); + if (message.method !== undefined && message.id === undefined) { + this.notifications.push(message); + this.onNotification?.(message); + } if (message.id !== undefined && message.method === undefined) { this.#pending.get(message.id)?.(message); this.#pending.delete(message.id); diff --git a/scripts/core-client.mjs b/scripts/core-client.mjs new file mode 100644 index 0000000..db7c6c4 --- /dev/null +++ b/scripts/core-client.mjs @@ -0,0 +1,15 @@ +// Optional explicit local Core integration. No fallback when a supplied Core rejects the manifest. +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +export function coreCommand(command, payload) { + if (!process.env.LITHE_CORE_LIBRARY) throw new Error('Set LITHE_CORE_LIBRARY to the current host Core dynamic library.'); + const result = spawnSync(process.env.PYTHON ?? 'python3', [fileURLToPath(new URL('./core-command.py', import.meta.url)), process.env.LITHE_CORE_LIBRARY], { + input: JSON.stringify({ id: 'maven-integration', command, payload }), encoding: 'utf8', timeout: 15000, maxBuffer: 32 * 1024 * 1024, + }); + assert.equal(result.status, 0, result.error?.message ?? result.stderr); + const envelope = JSON.parse(result.stdout); + assert.equal(envelope.ok, true, JSON.stringify(envelope)); + assert.equal(envelope.data.status, 'ok', JSON.stringify(envelope.data)); + return envelope.data; +} diff --git a/scripts/core-command.py b/scripts/core-command.py new file mode 100644 index 0000000..2edeb34 --- /dev/null +++ b/scripts/core-command.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Test-only C ABI client. The caller owns its process deadline and library identity.""" +import ctypes +import json +import sys + +core = ctypes.CDLL(sys.argv[1]) +core.lithe_core_execute_json.argtypes = [ctypes.c_char_p] +core.lithe_core_execute_json.restype = ctypes.c_void_p +core.lithe_core_free_string.argtypes = [ctypes.c_void_p] +request = sys.stdin.buffer.read(32 * 1024 * 1024 + 1) +if len(request) > 32 * 1024 * 1024: + raise ValueError("Core test request exceeds 32 MiB") +result = core.lithe_core_execute_json(request) +if not result: + raise RuntimeError("Core returned no response") +try: + document = json.loads(ctypes.string_at(result)) +finally: + core.lithe_core_free_string(result) +print(json.dumps(document)) diff --git a/scripts/package-development.mjs b/scripts/package-development.mjs new file mode 100644 index 0000000..f38bb9b --- /dev/null +++ b/scripts/package-development.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// Explicit source-workspace validation only. Never writes dist/ or changes sdk.lock.json. +import { createHash } from 'node:crypto'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { runCommand, withInterrupts, mavenTimeout } from './process.mjs'; +import { createReport } from './report.mjs'; +import { bundleManifest } from './bundle-manifest.mjs'; + +if (process.argv.length !== 4 || process.argv[2] !== '--sdk-jar') throw new Error('Usage: node scripts/package-development.mjs --sdk-jar /absolute/path/to/development-sdk.jar'); +const sdk = resolve(process.argv[3]); +const digest = createHash('sha256').update(readFileSync(sdk)).digest('hex'); +const artifactRoot = resolve('.artifacts/native-development'); +mkdirSync(artifactRoot, { recursive: true }); +const run = mkdtempSync(join(artifactRoot, 'run-')); +const id = 'io.github.lithe-idea.maven-build'; +const report = createReport('native-development-package'); +await withInterrupts(async (signal) => { + const scratch = join(run, 'source'); + try { + // Isolated build state; the source, accepted dist package and released SDK lock stay read-only. + for (const dir of ['extension', 'backend']) cpSync(resolve(dir), join(scratch, dir), { + recursive: true, filter: (file) => file !== resolve(dir, 'target'), + }); + for (const [name, args] of [ + ['extension', []], ['backend', [`-Dlithe.sdk.jar=${sdk}`]], + ]) await report.step(`build ${name} with the explicit development SDK`, () => runCommand(process.env.MVN ?? 'mvn', + ['-B', '-ntp', '-q', '-f', join(scratch, name, 'pom.xml'), ...args, 'package'], { signal, timeoutMs: mavenTimeout() })); + const pkg = join(run, id); + await report.step('assemble a development-only package', () => { + cpSync(resolve(id), pkg, { recursive: true }); + mkdirSync(join(pkg, 'lib')); + cpSync(join(scratch, 'extension/target/lithe-maven-extension.jar'), join(pkg, 'lib/lithe-maven-extension.jar')); + cpSync(join(scratch, 'backend/target/lithe-maven-backend.jar'), join(pkg, 'lib/lithe-maven-backend.jar')); + for (const file of ['LICENSE', 'NOTICE']) cpSync(resolve(file), join(pkg, file)); + writeFileSync(join(run, 'identity.json'), JSON.stringify({ developmentOnly: true, sdk: { path: sdk, sha256: digest }, package: bundleManifest(pkg) }, null, 2) + '\n'); + writeFileSync(join(artifactRoot, 'latest.json'), JSON.stringify({ developmentOnly: true, package: pkg, sdkSHA256: digest }, null, 2) + '\n'); + }); + console.log(`LITHE_TEST_PACKAGE=${pkg}`); + } catch (error) { + process.exitCode = 1; + report.failure('development package failed', error); + console.error(error.stack); + } finally { + rmSync(scratch, { recursive: true, force: true }); + report.write(); + } +}); diff --git a/scripts/test-backend.mjs b/scripts/test-backend.mjs index aaa7cfa..19d8aa8 100644 --- a/scripts/test-backend.mjs +++ b/scripts/test-backend.mjs @@ -21,7 +21,7 @@ import { withInterrupts } from "./process.mjs"; import { createReport } from "./report.mjs"; const PACKAGE_DIR = "io.github.lithe-idea.maven-build"; -const jar = resolve("dist", "package", PACKAGE_DIR, "lib", "lithe-maven-backend.jar"); +const jar = resolve(process.env.LITHE_TEST_PACKAGE ?? resolve("dist", "package", PACKAGE_DIR), "lib", "lithe-maven-backend.jar"); if (!existsSync(jar)) { console.error("✗ dist/package is missing the backend; run node scripts/package.mjs first"); process.exit(1); @@ -153,12 +153,12 @@ async function test() { }); await check("changed options and .mvn/maven.config invalidate the snapshot", async () => { - assert.equal((await client.request("lithe/build/model", params({ optionArguments: ["-o"] }))).fakeRun, 4); + assert.equal((await client.request("lithe/build/model", params({ options: { offline: true } }))).fakeRun, 4); assert.ok(runs().at(-1).argv.includes("-o"), "option arguments reach Maven"); mkdirSync(join(root, ".mvn")); writeFileSync(join(root, ".mvn", "maven.config"), "-Pci\n"); - assert.equal((await client.request("lithe/build/model", params({ optionArguments: ["-o"] }))).fakeRun, 5); - assert.equal((await client.request("lithe/build/model", params({ optionArguments: ["-o"] }))).fakeRun, 5); + assert.equal((await client.request("lithe/build/model", params({ options: { offline: true } }))).fakeRun, 5); + assert.equal((await client.request("lithe/build/model", params({ options: { offline: true } }))).fakeRun, 5); }); await check("concurrent requests share one Maven run", async () => { diff --git a/scripts/test-native-core.mjs b/scripts/test-native-core.mjs new file mode 100644 index 0000000..f8e8a01 --- /dev/null +++ b/scripts/test-native-core.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// The production plugin, current Core C ABI, and a real Maven reactor in one workflow. +import assert from 'node:assert/strict'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { BackendClient, bounded } from './backend-client.mjs'; +import { coreCommand } from './core-client.mjs'; +import { bundleManifest } from './bundle-manifest.mjs'; +import { createReport } from './report.mjs'; +import { mavenTimeout, withInterrupts } from './process.mjs'; + +const id = 'io.github.lithe-idea.maven-build'; +const pkg = resolve(process.env.LITHE_TEST_PACKAGE ?? resolve('dist/package', id)); +const before = bundleManifest(pkg); +const manifest = JSON.parse(readFileSync(join(pkg, 'plugin.json'))); +const scratch = realpathSync(mkdtempSync(join(tmpdir(), 'lithe-maven-core-'))); +const workspace = join(scratch, 'workspace'); +const root = 'nested'; +const report = createReport('native-maven-core'); +let client; +let model; +let compilePlan; +let testPlan; +const options = { profiles: ['ci'] }; +const configured = process.env.MVN ?? 'mvn'; +let maven = configured; +if (!configured.includes('/')) { + const probe = spawnSync('/bin/sh', ['-c', 'command -v "$0"', configured], { encoding: 'utf8', timeout: 5000 }); + assert.equal(probe.status, 0, 'Maven must be installed for this integration lane'); + maven = probe.stdout.trim(); +} +const build = (command, request) => coreCommand(command, { manifest, buildSystemID: 'maven', request }); +const planned = extra => ({ root, options, pluginDirectory: pkg, workspaceStateDirectory: join(scratch, 'state'), tool: { executable: maven }, ...extra }); +const send = async (plan, operationID) => client.request(plan.provider.method, { ...plan.provider.params, operationID }); + +await withInterrupts(async (signal) => { + const stop = () => client?.kill(); + signal.addEventListener('abort', stop, { once: true }); + try { + mkdirSync(workspace); + cpSync(resolve('test/fixtures/reactor'), join(workspace, root), { recursive: true }); + client = new BackendClient({ java: process.env.LITHE_TEST_JAVA ?? 'java', jar: join(pkg, 'lib/lithe-maven-backend.jar'), + state: join(scratch, 'state'), timeoutMs: mavenTimeout() }); + await report.step('Core admits the new Maven capability and produces only Provider plans', async () => { + coreCommand('plugins.declarative.validate', { manifest }); + assert.equal((await client.initialize(workspace, [3])).capabilities.experimental.litheBuild.version, 3); + for (const task of manifest.buildSystems[0].tasks) { assert.equal(task.source, 'provider'); assert.deepEqual(task.arguments, []); } + assert.equal(manifest.buildSystems[0].test.source, 'provider'); + }); + await report.step('read and normalize the real Maven reactor through Core', async () => { + const plan = build('build.modelPlan', planned({})) .plan; + const result = await send(plan, 'model'); + model = build('build.decodeModel', { workspaceRoot: workspace, root, options, result }).model; + assert.deepEqual(model.projects.map(project => project.name).sort(), ['app', 'core', 'reactor']); + assert.ok(model.contentRoots.some(content => content.path === 'nested/core/src/main/java')); + }); + const project = model.projects.find(project => project.name === 'core'); + await report.step('Core plans a module build, Maven executes it, and the class file exists', async () => { + compilePlan = build('build.taskPlan', planned({ task: 'compile', project })).plan; + assert.equal(compilePlan.provider.method, 'lithe/build/execute'); + assert.equal(compilePlan.arguments, undefined); + const result = await send(compilePlan, 'compile'); + assert.equal(result.exitCode, 0, result.output); + assert.ok(existsSync(join(workspace, 'nested/core/target/classes/demo/core/Greeter.class'))); + const decoded = build('build.decodeDiagnostics', { workspaceRoot: workspace, root, output: result.output }); + assert.deepEqual(decoded.diagnostics, []); + }); + await report.step('Core and Surefire run one exact method and decode one report result', async () => { + testPlan = build('build.testPlan', planned({ project, suite: 'demo.core.GreeterTest', case: 'greetsByName', userArguments: [] })).plan; + assert.equal(testPlan.provider.method, 'lithe/build/test'); + const result = await send(testPlan, 'exact-test'); + assert.equal(result.exitCode, 0, result.output); + const tests = build('build.decodeTestResults', { workspaceRoot: workspace, root, project: project.path, projects: model.projects.map(project => project.path), + suite: 'demo.core.GreeterTest', output: result.output }).results; + assert.equal(tests.summary.total, 1, JSON.stringify(tests)); + assert.equal(tests.summary.failed, 0, JSON.stringify(tests)); + assert.equal(tests.tests[0].case, 'greetsByName'); + const progress = client.notifications.filter(message => message.method === 'lithe/build/progress' && message.params.operationID === 'exact-test'); + assert.equal(progress.map(message => message.params.output ?? '').join(''), result.output); + }); + await report.step('an unknown method cannot pass with a fresh empty Surefire report', async () => { + const plan = build('build.testPlan', planned({ project, suite: 'demo.core.GreeterTest', case: 'doesNotExist', userArguments: [] })).plan; + await assert.rejects(send(plan, 'unknown-test'), /no fresh result/); + }); + await report.step('publish the same normalized Maven facts and effective options to a declared consumer', () => { + const consumer = JSON.parse(JSON.stringify(manifest)); + consumer.id = 'org.example.maven-input-consumer'; + consumer.requires.buildProviders = 6; + delete consumer.buildSystems[0].modelSharing; + consumer.buildSystems[0].modelInputs = [{ pluginID: id, buildSystemID: 'maven' }]; + const snapshot = coreCommand('build.resolveModelInputs', { manifest: consumer, buildSystemID: 'maven', request: { revision: 11, + sources: [{ manifest, instances: [{ buildSystemID: 'maven', root, state: 'ready', model, options }] }] } }).modelInputs; + const facts = snapshot.inputs[0].instances[0]; + assert.equal(facts.model.projects.length, 3); + assert.deepEqual(facts.options.profiles, ['ci']); + assert.equal(facts.model.tasks, undefined); + assert.equal(facts.model.run, undefined); + assert.equal(facts.model.optionValues, undefined); + assert.equal(facts.options.settingsFile, undefined); + const plan = coreCommand('build.modelPlan', { manifest: consumer, buildSystemID: 'maven', request: planned({ modelInputs: snapshot }) }).plan; + assert.deepEqual(plan.provider.params.modelInputs, snapshot); + }); + await report.step('close the backend and preserve the entire plugin package', async () => { + assert.equal(await client.close(), 0); + assert.deepEqual(bundleManifest(pkg), before); + }); + } catch (error) { + process.exitCode = 1; + report.failure('real Core/Maven lane failed', error); + console.error(error.stack); + if (client) console.error(client.stderr); + } finally { + signal.removeEventListener('abort', stop); + stop(); + if (client) await bounded(client.exited, 'backend cleanup').catch(error => { process.exitCode = 1; console.error(error); }); + rmSync(scratch, { recursive: true, force: true }); + report.write(); + } +}); diff --git a/scripts/test-native.mjs b/scripts/test-native.mjs new file mode 100644 index 0000000..f06c527 --- /dev/null +++ b/scripts/test-native.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node +// A real Java backend with an instrumented Maven process. No shell plans or network. +import assert from 'node:assert/strict'; +import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { BackendClient, bounded } from './backend-client.mjs'; +import { bundleManifest } from './bundle-manifest.mjs'; +import { withInterrupts } from './process.mjs'; +import { createReport } from './report.mjs'; + +const id = 'io.github.lithe-idea.maven-build'; +const pkg = resolve(process.env.LITHE_TEST_PACKAGE ?? resolve('dist/package', id)); +const before = bundleManifest(pkg); +const scratch = realpathSync(mkdtempSync(join(tmpdir(), 'lithe-maven-native-'))); +const workspace = join(scratch, 'workspace'); +const root = join(workspace, 'nested'); +const state = join(scratch, 'state'); +const controlFile = join(scratch, 'control.json'); +const log = join(scratch, 'runs.log'); +const executable = join(scratch, 'mvn'); +const clients = new Set(); +const report = createReport('native-maven'); +const runs = () => existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : []; +const executionRuns = () => runs().filter(run => !run.argv.includes('pre-clean')); +const control = value => writeFileSync(controlFile, JSON.stringify(value)); +const alive = pid => { try { process.kill(pid, 0); return true; } catch (error) { if (error.code === 'ESRCH') return false; throw error; } }; +async function until(condition, description) { + const deadline = performance.now() + 10000; + while (!condition()) { + if (performance.now() > deadline) throw new Error(`Timed out: ${description}`); + // Bounded observation of an OS process/file boundary; successful RPCs use promises. + await new Promise(done => setTimeout(done, 20)); + } +} +const params = (extra = {}) => ({ buildSystemID: 'maven', root: 'nested', options: {}, optionArguments: [], environment: {}, + tool: { executable }, userArguments: [], ...extra }); +const selected = { name: 'core', path: 'nested/core' }; +async function start() { + const client = new BackendClient({ java: process.env.LITHE_TEST_JAVA ?? 'java', jar: join(pkg, 'lib/lithe-maven-backend.jar'), + state, javaOptions: [`-Duser.home=${join(scratch, 'home')}`], + env: { ...process.env, FAKE_MAVEN_CONTROL: controlFile, FAKE_MAVEN_LOG: log } }); + clients.add(client); + assert.equal((await client.initialize(workspace, [3])).capabilities.experimental.litheBuild.version, 3); + return client; +} +await withInterrupts(async (signal) => { + const stop = () => { for (const client of clients) client.kill(); }; + signal.addEventListener('abort', stop, { once: true }); + try { + mkdirSync(workspace); + cpSync(resolve('test/fixtures/reactor'), root, { recursive: true }); + writeFileSync(executable, `#!/bin/sh\nexec '${process.execPath.replaceAll("'", "'\\''")}' '${resolve('test/fixtures/fake-maven-backend.mjs').replaceAll("'", "'\\''")}' "$@"\n`); + chmodSync(executable, 0o755); + control({}); + let client = await start(); + await report.step('load the real backend model without a native command fallback', async () => { + const result = await client.request('lithe/build/model', params()); + assert.equal(result.projects.length, 3); + assert.equal(runs().length, 1); + const dependencies = await client.request('lithe/build/dependencies', params({ project: { name: 'app', path: 'nested/app' } })); + assert.equal(dependencies.dependencies[0].name, 'core', 'nested workspace-relative dependency selection'); + }); + await report.step('native lifecycle uses current nested project, options and exact arguments', async () => { + const result = await client.request('lithe/build/execute', params({ task: 'package', project: selected, operationID: 'package', + options: { profiles: ['ci'], offline: true, skipTests: true, settingsFile: '/fixture settings.xml', localRepository: '/fixture repository' }, + userArguments: ['-Dmessage=two words'] })); + assert.equal(result.exitCode, 0); + const run = executionRuns().at(-1); + assert.equal(run.cwd, root); + assert.deepEqual(run.argv, ['-B', '-ntp', '-Pci', '-s', '/fixture settings.xml', '-Dmaven.repo.local=/fixture repository', '-o', '-DskipTests', '-pl', 'core', 'package', '-Dmessage=two words']); + const progress = client.notifications.filter(message => message.method === 'lithe/build/progress' && message.params.operationID === 'package'); + assert.equal(progress.map(message => message.params.output ?? '').join(''), result.output); + assert.match(result.output, /\[WARNING\]/); + }); + await report.step('model goals are checked against the current Maven extension output', async () => { + const modelTask = { name: 'compiler:compile', path: 'nested/core', properties: { goal: 'org.apache.maven.plugins:maven-compiler-plugin:3.13.0:compile' } }; + const result = await client.request('lithe/build/execute', params({ task: 'plugin-goal', project: selected, modelTask, operationID: 'goal' })); + assert.equal(result.exitCode, 0); + assert.ok(executionRuns().at(-1).argv.includes(modelTask.properties.goal)); + const count = executionRuns().length; + await assert.rejects(client.request('lithe/build/execute', params({ task: 'plugin-goal', project: selected, + modelTask: { ...modelTask, properties: { goal: 'unpublished:goal' } }, operationID: 'forged-goal' })), /absent from the current Maven model/); + assert.equal(executionRuns().length, count); + }); + await report.step('Surefire class/method selection stays exact and builds reactor prerequisites', async () => { + const result = await client.request('lithe/build/test', params({ project: selected, suite: 'demo.core.GreeterTest', case: 'greets', + options: { skipTests: true }, operationID: 'exact-test' })); + assert.equal(result.exitCode, 0); + assert.deepEqual(executionRuns().at(-1).argv, ['-B', '-ntp', '-pl', 'core', '-am', '-Dtest=demo.core.GreeterTest#greets', '-Dsurefire.failIfNoSpecifiedTests=false', 'test']); + const count = executionRuns().length; + await assert.rejects(client.request('lithe/build/test', params({ project: selected, suite: '*', operationID: 'wildcard' })), /exact Surefire/); + await assert.rejects(client.request('lithe/build/test', params({ project: selected, case: 'greets', operationID: 'no-suite' })), /needs its suite/); + assert.equal(executionRuns().length, count); + }); + await report.step('an old or empty Surefire report cannot make an unknown selector pass', async () => { + control({ execution: { noReport: true } }); + await assert.rejects(client.request('lithe/build/test', params({ project: selected, suite: 'demo.core.GreeterTest', case: 'greets', + operationID: 'stale-test' })), /no fresh result/); + control({}); + }); + await report.step('a fully qualified test selector cannot match a different package suffix', async () => { + control({ execution: { reportSuite: 'other.demo.core.GreeterTest' } }); + await assert.rejects(client.request('lithe/build/test', params({ project: selected, suite: 'demo.core.GreeterTest', case: 'greets', + operationID: 'wrong-package-test' })), /no fresh result/); + control({}); + }); + await report.step('a custom goal preserves user arguments and an unknown project cannot execute', async () => { + await client.request('lithe/build/execute', params({ task: 'goal', operationID: 'custom', userArguments: ['validate', '-Dvalue=a b'] })); + assert.deepEqual(executionRuns().at(-1).argv, ['-B', '-ntp', 'validate', '-Dvalue=a b']); + const count = executionRuns().length; + for (const project of [{ name: 'missing', path: 'nested/core' }, { name: 'core', path: '../escape' }]) { + await assert.rejects(client.request('lithe/build/execute', params({ task: 'compile', project, operationID: 'bad-project' }))); + } + assert.equal(executionRuns().length, count); + }); + await report.step('a tool failure returns an exit status and bounded output', async () => { + control({ execution: { exit: 7 } }); + const result = await client.request('lithe/build/execute', params({ task: 'compile', operationID: 'failure' })); + assert.equal(result.exitCode, 7); + assert.match(result.output, /fake failure/); + control({}); + }); + await report.step('cancelling a native build stops Maven and its owned child', async () => { + const gate = join(scratch, 'blocked'); + control({ execution: { gate, child: true } }); + const pending = client.send('lithe/build/execute', params({ task: 'compile', operationID: 'cancel-build' })); + try { + await until(() => existsSync(gate + '.started'), 'Maven to reach its controlled gate'); + const pid = Number(readFileSync(gate + '.started', 'utf8').trim()); + const child = Number(readFileSync(log + '.children', 'utf8').trim().split('\n').at(-1)); + client.cancel(pending.id); + assert.equal((await pending.response).error?.code, -32800); + await until(() => !alive(pid) && !alive(child), 'cancelled Maven process tree to end'); + } finally { writeFileSync(gate, 'release'); control({}); } + assert.equal((await client.request('lithe/build/execute', params({ task: 'validate', operationID: 'after-cancel' }))).exitCode, 0); + }); + await report.step('an oversized transcript fails and releases its Maven process', async () => { + control({ execution: { flood: true } }); + await assert.rejects(client.request('lithe/build/execute', params({ task: 'compile', operationID: 'overflow' })), /transcript/); + const pid = executionRuns().at(-1).pid; + await until(() => !alive(pid), 'oversized output process to end'); + control({}); + }); + await report.step('test teardown also reclaims SDK tools in a separate process group', async () => { + const other = await start(); + const gate = join(scratch, 'teardown-blocked'); + control({ execution: { gate, child: true } }); + const pending = other.send('lithe/build/execute', params({ task: 'compile', operationID: 'teardown' })); + // Register rejection handling before the owner is deliberately killed. + const rejected = assert.rejects(pending.response, /backend exited/); + try { + await until(() => existsSync(gate + '.started'), 'the separately grouped tool to start'); + const pid = Number(readFileSync(gate + '.started', 'utf8').trim()); + const child = Number(readFileSync(log + '.children', 'utf8').trim().split('\n').at(-1)); + other.kill(); + await rejected; + await bounded(other.exited, 'the stopped backend'); + await until(() => !alive(pid) && !alive(child), 'teardown to reclaim the detached SDK process group'); + clients.delete(other); + } finally { writeFileSync(gate, 'release'); control({}); other.kill(); } + }); + await report.step('ending the backend keeps the installed package byte-for-byte read-only', async () => { + assert.equal(await client.close(), 0); + clients.delete(client); + assert.deepEqual(bundleManifest(pkg), before); + }); + } catch (error) { + process.exitCode = 1; + report.failure('native Maven lane failed', error); + console.error(error.stack); + } finally { + signal.removeEventListener('abort', stop); + stop(); + for (const client of clients) await bounded(client.exited, 'backend cleanup').catch(error => { process.exitCode = 1; console.error(error); }); + rmSync(scratch, { recursive: true, force: true }); + report.write(); + } +}); diff --git a/scripts/test.mjs b/scripts/test.mjs index a14fee1..e66223b 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -1,8 +1,7 @@ #!/usr/bin/env node // Runs test/fixtures/reactor with a real Maven: the model and dependency -// trees through the packaged build provider (lib/lithe-maven-backend.jar, -// which runs Maven with the extension), and the commands plugin.json -// declares. It checks the documents Lithe receives; Lithe's own tests decode +// trees and native tests through the packaged build provider +// (lib/lithe-maven-backend.jar, which runs Maven with the extension). It checks the documents Lithe receives; Lithe's own tests decode // them, this script makes sure Maven produces them. The provider's caching is // covered by scripts/test-backend.mjs with a fake Maven. // @@ -25,7 +24,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; const PACKAGE_DIR = "io.github.lithe-idea.maven-build"; -const pluginDir = resolve("dist", "package", PACKAGE_DIR); +const pluginDir = resolve(process.env.LITHE_TEST_PACKAGE ?? resolve("dist", "package", PACKAGE_DIR)); const maven = process.env.MVN ?? "mvn"; if (!existsSync(join(pluginDir, "lib", "lithe-maven-extension.jar")) || !existsSync(join(pluginDir, "lib", "lithe-maven-backend.jar"))) { console.error("✗ dist/package is missing; run node scripts/package.mjs first"); @@ -34,44 +33,8 @@ if (!existsSync(join(pluginDir, "lib", "lithe-maven-extension.jar")) || !existsS const manifest = JSON.parse(readFileSync(join(pluginDir, "plugin.json"), "utf8")); const buildSystem = manifest.buildSystems[0]; -/** - * Expands a declared argument list the way Lithe does for this test: no - * option has a value, so `${optionArguments:…}` contributes nothing, and a - * conditional argument is kept when its conditions hold. - */ -function expand(list, { project, suite }) { - const conditions = { - projectSelected: project !== undefined, - noProjectSelected: project === undefined, - nestedProjectSelected: project !== undefined && project !== ".", - suiteSelected: suite !== undefined, - caseSelected: false, - noCaseSelected: true, - }; - const result = []; - for (const item of list) { - const value = typeof item === "string" ? item : item.value; - const when = typeof item === "string" ? [] : [item.when].flat(); - if (!when.every((condition) => conditions[condition])) continue; - if (/^\$\{optionArguments(:[A-Za-z]+)?\}$/.test(value)) continue; - result.push( - value - .replaceAll("${pluginDir}", pluginDir) - .replaceAll("${project.path}", project ?? "") - .replaceAll("${test.suite}", suite ?? ""), - ); - } - return result; -} - const report = createReport("maven-integration"); const timeoutMs = mavenTimeout(); -function run(name, root, list, signal, values = {}) { - const argv = [...expand(buildSystem.invocation.globalArguments, values), ...expand(list, values)]; - return report.step(name, () => runCommand(maven, argv, { - cwd: root, stdio: ["ignore", "ignore", "inherit"], timeoutMs, signal, - })); -} const scratch = realpathSync(mkdtempSync(join(tmpdir(), "lithe-maven-test-"))); const root = join(scratch, "reactor"); @@ -115,7 +78,7 @@ async function test(signal) { state: join(scratch, "state"), timeoutMs, }); - await report.step("start the build provider", () => backend.initialize(root)); + await report.step("start the build provider", () => backend.initialize(root, [3])); const params = (extra = {}) => ({ buildSystemID: "maven", root: ".", options: {}, optionArguments: [], environment: {}, tool: { executable: mavenExecutable() }, ...extra, @@ -185,11 +148,11 @@ async function test(signal) { assert.equal(junit.children[0].name, "hamcrest-core"); }); - await check("one Maven run serves the model and every tree, and is saved for the next start", async () => { + await check("one Maven model run serves every tree and leaves a reusable snapshot", async () => { const saved = JSON.parse(readFileSync(join(scratch, "state", "maven-snapshot.json"), "utf8")).value; assert.equal(saved.snapshotVersion, 1); assert.deepEqual(saved.roots[root].model, model); - assert.equal(await backend.close(), 0); + // The provider remains alive for the native task/test requests below. }); await check("reading the model and dependencies builds nothing", () => { @@ -198,7 +161,11 @@ async function test(signal) { } }); - await run("run the filtered Maven test suite", root, buildSystem.test.arguments, signal, { project: "core", suite: "demo.core.GreeterTest" }); + await report.step("run the filtered Maven test suite through its provider", async () => { + const result = await backend.request("lithe/build/test", params({ project: { name: "core", path: "core" }, + suite: "demo.core.GreeterTest", userArguments: [], operationID: "test-suite" })); + assert.equal(result.exitCode, 0, result.output); + }); await check("a filtered test run writes the report the results glob reads", () => { const report = readFileSync(join(root, "core", "target", "surefire-reports", "TEST-demo.core.GreeterTest.xml"), "utf8"); assert.match(report, /]*name="demo\.core\.GreeterTest"[^>]*tests="2"/); diff --git a/test/fixtures/fake-maven-backend.mjs b/test/fixtures/fake-maven-backend.mjs index ebcff90..7f2221e 100644 --- a/test/fixtures/fake-maven-backend.mjs +++ b/test/fixtures/fake-maven-backend.mjs @@ -7,12 +7,14 @@ // { "exit": } print an [ERROR] line and exit with the status // { "output": "missing" | "malformed" } succeed without the documents, or with invalid JSON import { spawn } from "node:child_process"; -import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { appendFileSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; -const control = existsSync(process.env.FAKE_MAVEN_CONTROL ?? "") +const configuration = existsSync(process.env.FAKE_MAVEN_CONTROL ?? "") ? JSON.parse(readFileSync(process.env.FAKE_MAVEN_CONTROL, "utf8")) : {}; +const exporting = process.argv.some((argument) => argument.startsWith("-Dlithe.modelOutput=")); +const control = exporting ? configuration : (configuration.execution ?? configuration); const log = process.env.FAKE_MAVEN_LOG; const argv = process.argv.slice(2); appendFileSync(log, `${JSON.stringify({ pid: process.pid, self: process.env.FAKE_MAVEN_SELF, cwd: process.cwd(), argv })}\n`); @@ -39,6 +41,20 @@ const value = (name) => argv.find((arg) => arg.startsWith(`-D${name}=`))?.slice( if (value("lithe.dependenciesProject") !== undefined) throw new Error("the backend must export every module"); const modelOutput = value("lithe.modelOutput"); const dependenciesOutput = value("lithe.dependenciesOutput"); +if (!exporting) { + console.log("[INFO] native Maven execution"); + console.log("[WARNING] " + join(process.cwd(), "core/src/main/java/demo/core/Greeter.java") + ":[1,1] test diagnostic"); + const selector = argv.find(argument => argument.startsWith("-Dtest="))?.slice(7); + if (selector && !control.noReport) { + const [selectedSuite, method = "greets"] = selector.split("#"); + const suite = control.reportSuite ?? selectedSuite; + const directory = join(process.cwd(), "core/target/surefire-reports"); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "TEST-" + suite + ".xml"), ``); + } + if (control.flood) await new Promise((done, reject) => process.stdout.write("x".repeat(9 * 1024 * 1024), error => error ? reject(error) : done())); + process.exit(0); +} if (!modelOutput || !dependenciesOutput) throw new Error("expected both output properties"); if (control.output === "missing") process.exit(0); if (control.output === "malformed") { @@ -86,7 +102,8 @@ writeFileSync(modelOutput, JSON.stringify({ ...(project.parentPath ? { parent: project.parentPath } : {}), properties: { groupId: project.groupId, artifactId: project.artifactId, version: project.version, packaging: project.packaging }, })), - tasks: [], + tasks: [{ name: "compiler:compile", project: projects[1].path, path: projects[1].path, + template: "plugin-goal", group: "build", properties: { goal: "org.apache.maven.plugins:maven-compiler-plugin:3.13.0:compile" } }], contentRoots: [], })); writeFileSync(dependenciesOutput, JSON.stringify({