diff --git a/component-starter-server/pom.xml b/component-starter-server/pom.xml index 493239f96b90f..4417ef7c8f5fe 100644 --- a/component-starter-server/pom.xml +++ b/component-starter-server/pom.xml @@ -462,16 +462,6 @@ - - - org.codehaus.gmavenplus - gmavenplus-plugin - - true - - - - grab-maven-wrapper diff --git a/component-tools-webapp/src/main/java/org/talend/sdk/component/tools/webapp/standalone/generator/StaticResourceGenerator.java b/component-tools-webapp/src/main/java/org/talend/sdk/component/tools/webapp/standalone/generator/StaticResourceGenerator.java index 32de35b98fdc7..90795a8440175 100644 --- a/component-tools-webapp/src/main/java/org/talend/sdk/component/tools/webapp/standalone/generator/StaticResourceGenerator.java +++ b/component-tools-webapp/src/main/java/org/talend/sdk/component/tools/webapp/standalone/generator/StaticResourceGenerator.java @@ -381,14 +381,15 @@ private byte[] swallow(final Supplier dependency) { } } - private static Route route(final String id, final String path, final SortedMap queries, + // visible for testing + static Route route(final String id, final String path, final SortedMap queries, final Map requestHeaders, final Map responseHeaders, final String content) { return new Route(id, 200, path, queries, responseHeaders.isEmpty() ? singletonMap("Accept", responseHeaders.getOrDefault("Content-Type", "application/json")) : requestHeaders, responseHeaders.isEmpty() ? singletonMap("Content-Type", "application/json") : responseHeaders, - content.getBytes(StandardCharsets.UTF_8)); + content == null ? new byte[0] : content.getBytes(StandardCharsets.UTF_8)); } private static Route route(final String id, final String path, final SortedMap queries, diff --git a/component-tools-webapp/src/test/java/org/talend/sdk/component/tools/webapp/standalone/generator/StaticResourceGeneratorTest.java b/component-tools-webapp/src/test/java/org/talend/sdk/component/tools/webapp/standalone/generator/StaticResourceGeneratorTest.java new file mode 100644 index 0000000000000..dc6e396e3120e --- /dev/null +++ b/component-tools-webapp/src/test/java/org/talend/sdk/component/tools/webapp/standalone/generator/StaticResourceGeneratorTest.java @@ -0,0 +1,49 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.tools.webapp.standalone.generator; + +import static java.util.Collections.emptyMap; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.util.TreeMap; + +import org.junit.jupiter.api.Test; +import org.talend.sdk.component.tools.webapp.standalone.Route; + +class StaticResourceGeneratorTest { + + @Test + void routeWithNullContentHasEmptyBody() { + final Route route = StaticResourceGenerator + .route("component_server_environment", "/api/v1/cache/clear", new TreeMap<>(), emptyMap(), emptyMap(), + null); + + assertEquals(200, route.getStatus()); + assertEquals("/api/v1/cache/clear", route.getPath()); + assertArrayEquals(new byte[0], route.getContent()); + } + + @Test + void routeWithContentKeepsUtf8Body() { + final Route route = StaticResourceGenerator + .route("component_server_environment", "/api/v1/environment", new TreeMap<>(), emptyMap(), emptyMap(), + "{\"a\":\"é\"}"); + + assertArrayEquals("{\"a\":\"é\"}".getBytes(StandardCharsets.UTF_8), route.getContent()); + } +} diff --git a/repository-knowledge.md b/repository-knowledge.md index a52a1c00c8e60..f3a8b02e2d73b 100644 --- a/repository-knowledge.md +++ b/repository-knowledge.md @@ -133,6 +133,29 @@ The repo has **two** JUnit testing modules under `component-runtime-testing/`: Always depend on `component-runtime-junit` for new test code. +### `uispec` goal — isolated classloader and how to verify it + +[2026-10-05 | QTDI-3531] `talend-component:uispec` (`StaticUiSpecGenerator`) starts an embedded server, so it must not run in +the mojo's own realm: that realm contains the plugin dependencies plus the transitive dependencies of +every connector CAR declared as plugin dependency, and a CXF 4 (jakarta servlet) there shadows CXF 3.6 +(javax) → `VerifyError`. The design therefore relies on an isolated classloader +(`UiSpecGeneratorMojo` + `uispec-generator.properties`, versions kept in lock-step with the plugin +version), guarded by the invoker IT `src/it/uispec`. The invoker configuration lives in the `ci-build` +profile (also auto-activated when `JENKINS_URL` is set), and the IT must run against the freshly +packaged plugin (a bare `invoker:run` resolves a stale remote SNAPSHOT instead): + +```bash +mvn verify -pl talend-component-maven-plugin -Pci-build -DskipTests -Dinvoker.test=uispec +``` + +The mojo resolves the generator classpath from `${project.remoteProjectRepositories}` (the artifacts are +regular dependencies, not plugins); `UiSpecGeneratorMojoTest` covers the isolation logic in the default build. + +[2026-10-05 | QTDI-3531] Manual validation against real connectors: in +`connectors/cloud/cloud-components-docker`, run +`mvn -o talend-component:uispec -PPUSH_DOCKER -Dcomponent-runtime.version=` +(~2 min, produces `target/talend-component-kit/uispec.zip`). + --- ## CI / Jenkinsfile patterns diff --git a/talend-component-maven-plugin/pom.xml b/talend-component-maven-plugin/pom.xml index 4af3c2d481c18..6709c236a5129 100644 --- a/talend-component-maven-plugin/pom.xml +++ b/talend-component-maven-plugin/pom.xml @@ -147,6 +147,22 @@ + + + src/main/resources + + org/talend/sdk/component/maven/uispec-generator.properties + + + + + src/main/resources + true + + org/talend/sdk/component/maven/uispec-generator.properties + + + org.apache.maven.plugins diff --git a/talend-component-maven-plugin/src/it/uispec/.mvn/jvm.config b/talend-component-maven-plugin/src/it/uispec/.mvn/jvm.config new file mode 100644 index 0000000000000..d551cde9369db --- /dev/null +++ b/talend-component-maven-plugin/src/it/uispec/.mvn/jvm.config @@ -0,0 +1 @@ +--add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/sun.nio.cs=ALL-UNNAMED --add-opens=java.base/sun.security.action=ALL-UNNAMED --add-opens=java.base/sun.security.x509=ALL-UNNAMED --add-opens=java.base/sun.util.calendar=ALL-UNNAMED diff --git a/talend-component-maven-plugin/src/it/uispec/pom.xml b/talend-component-maven-plugin/src/it/uispec/pom.xml new file mode 100644 index 0000000000000..bdc79f4353b22 --- /dev/null +++ b/talend-component-maven-plugin/src/it/uispec/pom.xml @@ -0,0 +1,78 @@ + + + + 4.0.0 + + org.talend.sdk.component + component-runtime + @project.version@ + + + sample-it-uispec + Component Runtime :: Maven Plugin :: IT :: UiSpec + + + ${project.basedir} + ${talend.build.name.base}.sample.it.uispec + true + + + + + org.talend.sdk.component + component-api + @project.version@ + provided + + + + + + + org.talend.sdk.component + talend-component-maven-plugin + @project.version@ + + + org.talend.components + @it-component-artifact-id@ + @it-component-version@ + component + car + + + + org.apache.cxf + cxf-rt-transports-http + 4.1.8 + + + + + uispec + + uispec + + package + + ${project.build.directory}/talend-component-kit/uispec.zip + + + + + + + diff --git a/talend-component-maven-plugin/src/it/uispec/postbuild.groovy b/talend-component-maven-plugin/src/it/uispec/postbuild.groovy new file mode 100644 index 0000000000000..f17086c176ec5 --- /dev/null +++ b/talend-component-maven-plugin/src/it/uispec/postbuild.groovy @@ -0,0 +1,38 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import java.nio.file.Paths +import java.util.zip.ZipFile + +/** + * Checks that the uispec mojo generated its archive even if the plugin classpath is polluted by conflicting libraries + */ +static def checkUiSpecMojo(final File basedir) { + final File zip = Paths.get(basedir.toString()).resolve('target/talend-component-kit/uispec.zip').toFile() + if (!zip.exists()) { + throw new FileNotFoundException("Could not find uispec archive ${zip}") + } + final ZipFile archive = new ZipFile(zip) + try { + final List names = archive.entries().collect { it.name } + assert names.contains('component/') + assert names.contains('configuration/') + assert names.any { it.startsWith('component/') && it != 'component/' } + } finally { + archive.close() + } +} + +checkUiSpecMojo(basedir) diff --git a/talend-component-maven-plugin/src/main/java/org/talend/sdk/component/maven/UiSpecGeneratorMojo.java b/talend-component-maven-plugin/src/main/java/org/talend/sdk/component/maven/UiSpecGeneratorMojo.java index f48d8577fb59f..f7c59fccbb77b 100644 --- a/talend-component-maven-plugin/src/main/java/org/talend/sdk/component/maven/UiSpecGeneratorMojo.java +++ b/talend-component-maven-plugin/src/main/java/org/talend/sdk/component/maven/UiSpecGeneratorMojo.java @@ -20,31 +20,63 @@ import static org.talend.sdk.component.maven.api.Audience.Type.TALEND_INTERNAL; import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; import java.util.Collection; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Properties; +import java.util.stream.Stream; import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.descriptor.PluginDescriptor; import org.apache.maven.plugins.annotations.Component; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.project.MavenProjectHelper; +import org.eclipse.aether.RepositorySystem; +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.artifact.DefaultArtifact; +import org.eclipse.aether.collection.CollectRequest; +import org.eclipse.aether.graph.Dependency; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.resolution.ArtifactResult; +import org.eclipse.aether.resolution.DependencyRequest; +import org.eclipse.aether.resolution.DependencyResolutionException; import org.talend.sdk.component.maven.api.Audience; -import org.talend.sdk.component.tools.webapp.standalone.generator.StaticUiSpecGenerator; @Audience(TALEND_INTERNAL) @Mojo(name = "uispec", defaultPhase = PACKAGE, threadSafe = true, requiresDependencyResolution = TEST) public class UiSpecGeneratorMojo extends BuildComponentM2RepositoryMojo { + private static final String GENERATOR_CLASS = + "org.talend.sdk.component.tools.webapp.standalone.generator.StaticUiSpecGenerator"; + + private static final String VERSIONS_RESOURCE = "uispec-generator.properties"; + @Parameter(defaultValue = "en") private Collection languages; @Parameter(defaultValue = "${maven.multiModuleProjectDirectory}/target/talend-component-kit/uispec.zip") private File uiSpecZip; + @Parameter(defaultValue = "${plugin}", readonly = true) + private PluginDescriptor pluginDescriptor; + + @Parameter(defaultValue = "${project.remoteProjectRepositories}", readonly = true) + private List projectRepositories; + @Component private MavenProjectHelper helper; + @Component + private RepositorySystem uiSpecRepositorySystem; + @Override public void doExecute() throws MojoExecutionException { super.doExecute(); @@ -52,7 +84,92 @@ public void doExecute() throws MojoExecutionException { setup.put("talend.component.server.maven.repository", m2Root.getAbsolutePath()); setup.put("talend.component.server.component.registry", getRegistry().toAbsolutePath().toString()); setup.put("talend.component.server.component.extend.dependencies", "false"); - new StaticUiSpecGenerator(setup, languages, uiSpecZip.toPath()).run(); + generate(setup); helper.attachArtifact(project, "zip", "uispec", uiSpecZip); } + + /** + * The plugin classloader also contains the dependencies of the components declared as plugin dependencies, + * which can shadow the libraries the embedded server needs (servlet APIs, CXF, ...) or add unrelated CDI + * extensions. The generator therefore runs in its own classloader, built like the component server image: + * the webapp graph plus the CDI container and Beam, which the components expect from the runtime. + */ + private void generate(final Map setup) throws MojoExecutionException { + runIsolated(isolatedClasspath(), GENERATOR_CLASS, setup, languages, uiSpecZip.toPath()); + } + + static void runIsolated(final URL[] classpath, final String generatorClass, final Map setup, + final Collection languages, final Path output) throws MojoExecutionException { + final Thread thread = Thread.currentThread(); + final ClassLoader contextLoader = thread.getContextClassLoader(); + try (final URLClassLoader loader = new URLClassLoader(classpath, ClassLoader.getPlatformClassLoader())) { + thread.setContextClassLoader(loader); + final Runnable generator = (Runnable) loader + .loadClass(generatorClass) + .getConstructor(Map.class, Collection.class, Path.class) + .newInstance(setup, languages, output); + generator.run(); + } catch (final IOException | ReflectiveOperationException e) { + throw new MojoExecutionException(e.getMessage(), e); + } finally { + thread.setContextClassLoader(contextLoader); + } + } + + private URL[] isolatedClasspath() throws MojoExecutionException { + return resolveClasspath(uiSpecRepositorySystem, repositorySystemSession, projectRepositories, + pluginDescriptor.getVersion(), loadGeneratorVersions()); + } + + static URL[] resolveClasspath(final RepositorySystem repositorySystem, final RepositorySystemSession session, + final List repositories, final String pluginVersion, final Properties versions) + throws MojoExecutionException { + final CollectRequest collect = new CollectRequest().setRepositories(repositories); + Stream + .of(new DefaultArtifact("org.talend.sdk.component", "component-tools-webapp", "jar", pluginVersion), + new DefaultArtifact("org.talend.sdk.component", "component-runtime-beam", "jar", + pluginVersion), + new DefaultArtifact("org.apache.openwebbeans", "openwebbeans-se", "jar", + versions.getProperty("openwebbeans.version")), + new DefaultArtifact("org.apache.beam", "beam-sdks-java-core", "jar", + versions.getProperty("beam.version"))) + .map(artifact -> new Dependency(artifact, "runtime")) + .forEach(collect::addDependency); + try { + return repositorySystem + .resolveDependencies(session, new DependencyRequest(collect, null)) + .getArtifactResults() + .stream() + .map(ArtifactResult::getArtifact) + .map(a -> toUrl(a.getFile())) + .toArray(URL[]::new); + } catch (final DependencyResolutionException e) { + throw new MojoExecutionException("Can't resolve the uispec generator classpath: " + e.getMessage(), e); + } + } + + static Properties loadGeneratorVersions() throws MojoExecutionException { + return readVersions(UiSpecGeneratorMojo.class.getResourceAsStream(VERSIONS_RESOURCE)); + } + + static Properties readVersions(final InputStream resource) throws MojoExecutionException { + try (final InputStream stream = resource) { + if (stream == null) { + throw new MojoExecutionException("Missing " + VERSIONS_RESOURCE + " in the plugin"); + } + final Properties versions = new Properties(); + versions.load(stream); + return versions; + } catch (final IOException e) { + throw new MojoExecutionException("Can't read " + VERSIONS_RESOURCE, e); + } + } + + private static URL toUrl(final File file) { + try { + return file.toURI().toURL(); + } catch (final MalformedURLException e) { + throw new IllegalStateException(e); + } + } } diff --git a/talend-component-maven-plugin/src/main/resources/org/talend/sdk/component/maven/uispec-generator.properties b/talend-component-maven-plugin/src/main/resources/org/talend/sdk/component/maven/uispec-generator.properties new file mode 100644 index 0000000000000..7ed2c2ec3544d --- /dev/null +++ b/talend-component-maven-plugin/src/main/resources/org/talend/sdk/component/maven/uispec-generator.properties @@ -0,0 +1,16 @@ +# Copyright (C) 2006-2026 Talend Inc. - www.talend.com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +openwebbeans.version=${owb.version} +beam.version=${beam.version} diff --git a/talend-component-maven-plugin/src/test/java/org/talend/sdk/component/maven/UiSpecGeneratorMojoTest.java b/talend-component-maven-plugin/src/test/java/org/talend/sdk/component/maven/UiSpecGeneratorMojoTest.java new file mode 100644 index 0000000000000..ae1dc35e6c531 --- /dev/null +++ b/talend-component-maven-plugin/src/test/java/org/talend/sdk/component/maven/UiSpecGeneratorMojoTest.java @@ -0,0 +1,349 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.maven; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.maven.plugin.MojoExecutionException; +import org.eclipse.aether.RepositorySystem; +import org.eclipse.aether.artifact.DefaultArtifact; +import org.eclipse.aether.collection.CollectRequest; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.resolution.ArtifactRequest; +import org.eclipse.aether.resolution.ArtifactResult; +import org.eclipse.aether.resolution.DependencyRequest; +import org.eclipse.aether.resolution.DependencyResolutionException; +import org.eclipse.aether.resolution.DependencyResult; +import org.junit.jupiter.api.Test; + +class UiSpecGeneratorMojoTest { + + @Test + void generatorVersionsAreFiltered() throws MojoExecutionException { + final Properties versions = UiSpecGeneratorMojo.loadGeneratorVersions(); + assertFalse(versions.getProperty("openwebbeans.version", "").isEmpty()); + assertFalse(versions.getProperty("beam.version", "").isEmpty()); + versions.forEach((key, value) -> assertFalse(String.valueOf(value).contains("${"), key + "=" + value)); + } + + @Test + void generatorRunsInIsolatedLoader() throws Exception { + final Thread thread = Thread.currentThread(); + final ClassLoader before = thread.getContextClassLoader(); + final Map setup = new HashMap<>(); + + UiSpecGeneratorMojo + .runIsolated(testClasspath(), RecordingGenerator.class.getName(), setup, List.of("en"), + Paths.get("uispec.zip")); + + assertEquals("true", setup.get("tccl.is.generator.loader")); + assertEquals("true", setup.get("loader.is.url.loader")); + assertNotEquals(String.valueOf(System.identityHashCode(getClass().getClassLoader())), + setup.get("generator.loader")); + assertEquals("en", setup.get("languages")); + assertEquals("uispec.zip", setup.get("output")); + assertSame(before, thread.getContextClassLoader()); + } + + @Test + void contextLoaderIsRestoredWhenGeneratorFails() { + final Thread thread = Thread.currentThread(); + final ClassLoader before = thread.getContextClassLoader(); + + final URL[] classpath = testClasspath(); + final String generator = FailingGenerator.class.getName(); + final Map setup = new HashMap<>(); + final List languages = List.of(); + final Path output = Paths.get("uispec.zip"); + + final IllegalStateException error = assertThrows(IllegalStateException.class, + () -> UiSpecGeneratorMojo.runIsolated(classpath, generator, setup, languages, output)); + + assertEquals("generator failure", error.getMessage()); + assertSame(before, thread.getContextClassLoader()); + } + + @Test + void unknownGeneratorIsWrapped() { + final Thread thread = Thread.currentThread(); + final ClassLoader before = thread.getContextClassLoader(); + + final URL[] classpath = testClasspath(); + final Map setup = new HashMap<>(); + final List languages = List.of(); + final Path output = Paths.get("uispec.zip"); + + final MojoExecutionException error = assertThrows(MojoExecutionException.class, + () -> UiSpecGeneratorMojo.runIsolated(classpath, "org.talend.Missing", setup, languages, output)); + + assertTrue(error.getCause() instanceof ClassNotFoundException); + assertSame(before, thread.getContextClassLoader()); + } + + @Test + void generatorWithoutExpectedConstructorIsWrapped() { + final URL[] classpath = testClasspath(); + final String generator = WrongConstructorGenerator.class.getName(); + final Map setup = new HashMap<>(); + final List languages = List.of(); + final Path output = Paths.get("uispec.zip"); + + final MojoExecutionException error = assertThrows(MojoExecutionException.class, + () -> UiSpecGeneratorMojo.runIsolated(classpath, generator, setup, languages, output)); + + assertTrue(error.getCause() instanceof NoSuchMethodException); + } + + @Test + void generatorInstantiationErrorIsWrapped() { + final URL[] classpath = testClasspath(); + final String generator = BrokenConstructorGenerator.class.getName(); + final Map setup = new HashMap<>(); + final List languages = List.of(); + final Path output = Paths.get("uispec.zip"); + + final MojoExecutionException error = assertThrows(MojoExecutionException.class, + () -> UiSpecGeneratorMojo.runIsolated(classpath, generator, setup, languages, output)); + + assertNotNull(error.getCause()); + assertTrue(error.getCause() instanceof InvocationTargetException); + } + + @Test + void classpathRequestCarriesRootsScopeAndRepositories() throws MojoExecutionException { + final RemoteRepository repository = new RemoteRepository.Builder("central", "default", "http://repo.local") + .build(); + final AtomicReference request = new AtomicReference<>(); + final RepositorySystem system = repositorySystem((req, session) -> { + request.set(req); + return new DependencyResult(req); + }); + + final URL[] classpath = + UiSpecGeneratorMojo.resolveClasspath(system, null, List.of(repository), "1.2.3", versions()); + + assertEquals(0, classpath.length); + final CollectRequest collect = request.get().getCollectRequest(); + assertEquals(List.of(repository), collect.getRepositories()); + assertEquals(List.of("org.talend.sdk.component:component-tools-webapp:jar:1.2.3", + "org.talend.sdk.component:component-runtime-beam:jar:1.2.3", + "org.apache.openwebbeans:openwebbeans-se:jar:9.9.1", + "org.apache.beam:beam-sdks-java-core:jar:9.9.2"), + collect.getDependencies().stream().map(d -> d.getArtifact().toString()).toList()); + assertTrue(collect.getDependencies().stream().allMatch(d -> "runtime".equals(d.getScope()))); + } + + @Test + void resolvedArtifactsAreConvertedToUrlsInOrder() throws MojoExecutionException { + final File first = new File("first.jar"); + final File second = new File("second.jar"); + final RepositorySystem system = repositorySystem((req, session) -> { + final DependencyResult result = new DependencyResult(req); + result.setArtifactResults(List.of(resolved(first), resolved(second))); + return result; + }); + + final URL[] classpath = UiSpecGeneratorMojo.resolveClasspath(system, null, List.of(), "1.2.3", versions()); + + assertEquals(2, classpath.length); + assertEquals(first.toURI().toString(), classpath[0].toString()); + assertEquals(second.toURI().toString(), classpath[1].toString()); + } + + @Test + void resolutionFailureIsWrapped() { + final Properties versions = versions(); + final List repositories = List.of(); + final RepositorySystem system = repositorySystem((req, session) -> { + throw new DependencyResolutionException(new DependencyResult(req), new IllegalStateException("offline")); + }); + + final MojoExecutionException error = assertThrows(MojoExecutionException.class, + () -> UiSpecGeneratorMojo.resolveClasspath(system, null, repositories, "1.2.3", versions)); + + assertTrue(error.getMessage().startsWith("Can't resolve the uispec generator classpath")); + assertTrue(error.getCause() instanceof DependencyResolutionException); + } + + @Test + void artifactWithMalformedUrlIsRejected() { + final File malformed = new File("malformed.jar") { + + @Override + public URI toURI() { + return URI.create("unknown-scheme://host/malformed.jar"); + } + }; + final Properties versions = versions(); + final List repositories = List.of(); + final RepositorySystem system = repositorySystem((req, session) -> { + final DependencyResult result = new DependencyResult(req); + result.setArtifactResults(List.of(resolved(malformed))); + return result; + }); + + final IllegalStateException error = assertThrows(IllegalStateException.class, + () -> UiSpecGeneratorMojo.resolveClasspath(system, null, repositories, "1.2.3", versions)); + + assertTrue(error.getCause() instanceof MalformedURLException); + } + + @Test + void missingVersionsResourceIsReported() { + final MojoExecutionException error = + assertThrows(MojoExecutionException.class, () -> UiSpecGeneratorMojo.readVersions(null)); + + assertEquals("Missing uispec-generator.properties in the plugin", error.getMessage()); + } + + @Test + void unreadableVersionsResourceIsWrapped() { + final IOException failure = new IOException("disk failure"); + final InputStream broken = new InputStream() { + + @Override + public int read() throws IOException { + throw failure; + } + }; + + final MojoExecutionException error = + assertThrows(MojoExecutionException.class, () -> UiSpecGeneratorMojo.readVersions(broken)); + + assertEquals("Can't read uispec-generator.properties", error.getMessage()); + assertSame(failure, error.getCause()); + } + + private static Properties versions() { + final Properties versions = new Properties(); + versions.setProperty("openwebbeans.version", "9.9.1"); + versions.setProperty("beam.version", "9.9.2"); + return versions; + } + + private static ArtifactResult resolved(final File file) { + return new ArtifactResult(new ArtifactRequest()) + .setArtifact(new DefaultArtifact("g", "a", "jar", "1").setFile(file)); + } + + // only resolveDependencies is used by the mojo, every other method of the resolver is left unsupported + private static RepositorySystem repositorySystem(final DependencyResolver resolver) { + return (RepositorySystem) Proxy + .newProxyInstance(UiSpecGeneratorMojoTest.class.getClassLoader(), + new Class[] { RepositorySystem.class }, (proxy, method, args) -> { + if (!"resolveDependencies".equals(method.getName())) { + throw new UnsupportedOperationException(method.getName()); + } + return resolver.resolve((DependencyRequest) args[1], args[0]); + }); + } + + @FunctionalInterface + private interface DependencyResolver { + + DependencyResult resolve(DependencyRequest request, Object session) throws DependencyResolutionException; + } + + // the isolated loader only has the platform loader as parent, so it loads the generators from the test classes + private static URL[] testClasspath() { + return new URL[] { UiSpecGeneratorMojoTest.class.getProtectionDomain().getCodeSource().getLocation() }; + } + + public static class RecordingGenerator implements Runnable { + + private final Map setup; + + private final Collection languages; + + private final Path output; + + public RecordingGenerator(final Map setup, final Collection languages, + final Path output) { + this.setup = setup; + this.languages = languages; + this.output = output; + } + + @Override + public void run() { + final ClassLoader loader = getClass().getClassLoader(); + setup.put("generator.loader", String.valueOf(System.identityHashCode(loader))); + setup.put("loader.is.url.loader", String.valueOf(loader instanceof URLClassLoader)); + setup.put("tccl.is.generator.loader", + String.valueOf(loader == Thread.currentThread().getContextClassLoader())); + setup.put("languages", String.join(",", languages)); + setup.put("output", output.toString()); + } + } + + public static class FailingGenerator implements Runnable { + + public FailingGenerator(final Map setup, final Collection languages, + final Path output) { + // constructor shape expected by the mojo + } + + @Override + public void run() { + throw new IllegalStateException("generator failure"); + } + } + + public static class WrongConstructorGenerator implements Runnable { + + @Override + public void run() { + // never reached + } + } + + public static class BrokenConstructorGenerator implements Runnable { + + public BrokenConstructorGenerator(final Map setup, final Collection languages, + final Path output) { + throw new IllegalArgumentException("broken"); + } + + @Override + public void run() { + // never reached + } + } +}