diff --git a/flow-plugins/flow-dev-bundle-plugin/src/main/java/com/vaadin/flow/plugin/maven/Reflector.java b/flow-plugins/flow-dev-bundle-plugin/src/main/java/com/vaadin/flow/plugin/maven/Reflector.java index 8c300b2f290..5abb4453413 100644 --- a/flow-plugins/flow-dev-bundle-plugin/src/main/java/com/vaadin/flow/plugin/maven/Reflector.java +++ b/flow-plugins/flow-dev-bundle-plugin/src/main/java/com/vaadin/flow/plugin/maven/Reflector.java @@ -56,7 +56,7 @@ public final class Reflector { "org.eclipse.sisu"); // Dependency required by the plugin but not provided by Flow at runtime private static final Set REQUIRED_PLUGIN_DEPENDENCIES = Set.of( - "org.reflections:reflections:jar", + "io.github.classgraph:classgraph:jar", "org.zeroturnaround:zt-exec:jar"); private static final ScopeArtifactFilter PRODUCTION_SCOPE_FILTER = new ScopeArtifactFilter( Artifact.SCOPE_COMPILE_PLUS_RUNTIME); diff --git a/flow-plugins/flow-maven-plugin/src/it/plugin-pinned-deps-project/invoker.properties b/flow-plugins/flow-maven-plugin/src/it/plugin-pinned-deps-project/invoker.properties index eba827e3ceb..6809a9c8cfd 100644 --- a/flow-plugins/flow-maven-plugin/src/it/plugin-pinned-deps-project/invoker.properties +++ b/flow-plugins/flow-maven-plugin/src/it/plugin-pinned-deps-project/invoker.properties @@ -14,4 +14,6 @@ # the License. # -invoker.goals=clean package +# Build will fail with a ClassNotFoundException if using the outdate version +# of the plugin required dependencies. +invoker.goals=clean package -ntp diff --git a/flow-plugins/flow-maven-plugin/src/it/plugin-pinned-deps-project/pom.xml b/flow-plugins/flow-maven-plugin/src/it/plugin-pinned-deps-project/pom.xml index fb3ccca89a7..787293df8ef 100644 --- a/flow-plugins/flow-maven-plugin/src/it/plugin-pinned-deps-project/pom.xml +++ b/flow-plugins/flow-maven-plugin/src/it/plugin-pinned-deps-project/pom.xml @@ -35,9 +35,9 @@ ${flow.version} - org.reflections - reflections - 0.9.10 + io.github.classgraph + classgraph + 4.0.0 org.zeroturnaround diff --git a/flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/Reflector.java b/flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/Reflector.java index f6c83b6bc9e..fcb3e6b85b5 100644 --- a/flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/Reflector.java +++ b/flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/Reflector.java @@ -69,7 +69,7 @@ public final class Reflector { "org.eclipse.sisu"); // Dependency required by the plugin but not provided by Flow at runtime private static final Set REQUIRED_PLUGIN_DEPENDENCIES = Set.of( - "org.reflections:reflections:jar", + "io.github.classgraph:classgraph:jar", "org.zeroturnaround:zt-exec:jar"); private static final ScopeArtifactFilter PRODUCTION_SCOPE_FILTER = new ScopeArtifactFilter( Artifact.SCOPE_COMPILE_PLUS_RUNTIME); diff --git a/flow-plugins/flow-plugin-base/pom.xml b/flow-plugins/flow-plugin-base/pom.xml index 443d68a66e3..9bea898e7fd 100644 --- a/flow-plugins/flow-plugin-base/pom.xml +++ b/flow-plugins/flow-plugin-base/pom.xml @@ -29,28 +29,9 @@ license-checker - org.reflections - reflections - 0.10.2 - - - org.dom4j - dom4j - - - com.google.code.gson - gson - - - org.javassist - javassist - - - - - org.javassist - javassist - ${javassist.version} + io.github.classgraph + classgraph + 4.8.184 org.zeroturnaround diff --git a/flow-plugins/flow-plugin-base/src/main/java/com/vaadin/flow/server/scanner/ReflectionsClassFinder.java b/flow-plugins/flow-plugin-base/src/main/java/com/vaadin/flow/server/scanner/ReflectionsClassFinder.java index b187c87d6b0..1d987955cf0 100644 --- a/flow-plugins/flow-plugin-base/src/main/java/com/vaadin/flow/server/scanner/ReflectionsClassFinder.java +++ b/flow-plugins/flow-plugin-base/src/main/java/com/vaadin/flow/server/scanner/ReflectionsClassFinder.java @@ -22,37 +22,81 @@ import java.net.URLClassLoader; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.LinkedHashSet; -import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; -import javassist.bytecode.ClassFile; - -import org.reflections.Configuration; -import org.reflections.Reflections; -import org.reflections.scanners.Scanner; -import org.reflections.util.ClasspathHelper; -import org.reflections.util.ConfigurationBuilder; -import org.reflections.util.NameHelper; -import org.reflections.util.QueryBuilder; -import org.reflections.vfs.Vfs; + +import io.github.classgraph.AnnotationInfo; +import io.github.classgraph.ClassGraph; +import io.github.classgraph.ClassInfo; +import io.github.classgraph.ClassInfoList; +import io.github.classgraph.ScanResult; +import nonapi.io.github.classgraph.utils.VersionFinder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.vaadin.flow.server.frontend.scanner.ClassFinder; /** - * A class finder using org.reflections. + * A class finder using io.github.classgraph. + *

+ * This implementation uses the ClassGraph library for fast classpath scanning. + * ClassGraph parses bytecode directly without loading classes, making it + * significantly faster than reflection-based approaches. + *

+ * Note: Despite the class name, this implementation uses ClassGraph library, + * not the org.reflections library. The class name is maintained for backward + * compatibility. * * @since 2.0 */ public class ReflectionsClassFinder implements ClassFinder { + /** + * System property name to be used to disable default package filtering + * during class scan. See {@link #applyScannerPackageFilters(ClassGraph)} + * and {@link #DEFAULT_REJECTED_PACKAGES} + */ + public static final String DISABLE_DEFAULT_PACKAGE_FILTER = "vaadin.classfinder.disableDefaultPackageFilter"; + + private static final String[] DEFAULT_REJECTED_PACKAGES = new String[] { + "antlr", "cglib", "ch.quos.logback", "commons-codec", + "commons-fileupload", "commons-io", "commons-logging", + "com.fasterxml", "tools.jackson", "com.google", "com.h2database", + "com.helger", "com.vaadin.external.atmosphere", "com.vaadin.webjar", + "junit", "net.bytebuddy", "org.apache", "org.aspectj", + "org.bouncycastle", "org.dom4j", "org.easymock", + "org.eclipse.persistence", "org.hamcrest", "org.hibernate", + "org.javassist", "org.jboss", "org.jsoup", "org.seleniumhq", + "org.slf4j", "org.atmosphere", "org.springframework", + "org.webjars.bowergithub", "org.yaml", + + "java.*", "javax.*", "javafx.*", "com.sun.*", "oracle.deploy", + "oracle.javafx", "oracle.jrockit", "oracle.jvm", "oracle.net", + "oracle.nio", "oracle.tools", "oracle.util", "oracle.webservices", + "oracle.xmlns", + + "com.intellij.*", "org.jetbrains", + + "com.vaadin.external.gwt", "javassist.*", "io.methvin", + "com.github.javaparser", "oshi.*", "io.micrometer", "jakarta.*", + "com.nimbusds", "elemental.util", "org.reflections", + "org.aopalliance", "org.objectweb", + + "com.vaadin.hilla", "com.vaadin.copilot" }; + private static final Logger LOGGER = LoggerFactory .getLogger(ReflectionsClassFinder.class); + private final transient ClassLoader classLoader; - private final transient Reflections reflections; + // Cache all discovered classes by annotation + private final transient Map> annotatedClassCache; + // Cache all discovered subclasses by parent type + private final transient Map> subtypeCache; + // Scanned packages for filtering + private final transient Set scannedPackages; /** * Constructor. @@ -65,41 +109,93 @@ public ReflectionsClassFinder(URL... urls) { Thread.currentThread().getContextClassLoader()), urls); } + /** + * Constructor with explicit class loader. + * + * @param classLoader + * the class loader to use for loading classes + * @param urls + * the list of urls for finding classes + */ public ReflectionsClassFinder(ClassLoader classLoader, URL... urls) { this.classLoader = classLoader; - ConfigurationBuilder configurationBuilder = new ConfigurationBuilder() - .addClassLoaders(classLoader).setExpandSuperTypes(false) - .addUrls(urls); - - ConfigurationBuilder.DEFAULT_SCANNERS - .forEach(configurationBuilder::addScanners); - configurationBuilder.addScanners(PackageScanner.INSTANCE); - configurationBuilder - .setInputsFilter(resourceName -> resourceName.endsWith(".class") - && !resourceName.endsWith("module-info.class")); - - // Adding the custom URL type handler at the end, as a last resort to - // prevent warning messages on server logs - // Vfs.getDefaultUrlTypes() gets the internal mutable collection - List defaultUrlTypes = Vfs.getDefaultUrlTypes(); - if (!defaultUrlTypes.contains(IGNORE_NOT_HANDLED_FILES)) { - defaultUrlTypes.add(IGNORE_NOT_HANDLED_FILES); + long startTime = System.currentTimeMillis(); + + // When URLs are empty or null, it means scan nothing (isolation mode) + // Initialize with empty caches instead of scanning + if (urls == null || urls.length == 0) { + this.scannedPackages = Collections.emptySet(); + this.annotatedClassCache = Collections.emptyMap(); + this.subtypeCache = Collections.emptyMap(); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + "ClassFinder initialized with empty scan URLs: 0 classes scanned"); + } + return; } - try { - reflections = new LoggingReflections(configurationBuilder); - } finally { - defaultUrlTypes.remove(IGNORE_NOT_HANDLED_FILES); + + // Configure ClassGraph scanner with provided URLs + ClassGraph classGraph = new ClassGraph() + .overrideClasspath((Object[]) urls).addClassLoader(classLoader) + .enableClassInfo().enableAnnotationInfo() + .ignoreClassVisibility() // Scan non-public classes + .filterClasspathElements( + path -> !path.endsWith("module-info.class")); + applyScannerPackageFilters(classGraph); + if (VersionFinder.JAVA_MAJOR_VERSION < 24) { + // Not available on Java 24+ currently, because of the deprecation + // of the Unsafe API + classGraph.enableMemoryMapping(); } + + // Scan and extract all data, then close immediately + int classCount; + try (ScanResult scanResult = classGraph.scan()) { + ClassInfoList allClasses = scanResult.getAllClasses(); + classCount = allClasses.size(); + + // Extract scanned packages + this.scannedPackages = allClasses.stream() + .map(classInfo -> extractPackageName(classInfo.getName())) + .filter(pkg -> !pkg.isEmpty()).collect(Collectors.toSet()); + + // Pre-cache all annotated classes + this.annotatedClassCache = buildAnnotatedClassCache(allClasses); + + // Pre-cache all subtype relationships + this.subtypeCache = buildSubtypeCache(allClasses); + + } // scanResult automatically closed here + + long duration = System.currentTimeMillis() - startTime; + LOGGER.info( + "ClassFinder initialized: {} urls, {} classes scanned, {} annotation types cached, {} subtype relationships cached, took {}ms", + urls.length, classCount, annotatedClassCache.size(), + subtypeCache.size(), duration); } @Override public Set> getAnnotatedClasses( Class clazz) { Set> classes = new LinkedHashSet<>(); - classes.addAll(reflections.getTypesAnnotatedWith(clazz, true)); + + // Get directly annotated classes from cache + Set classNames = annotatedClassCache + .getOrDefault(clazz.getName(), Collections.emptySet()); + + for (String className : classNames) { + try { + classes.add(classLoader.loadClass(className)); + } catch (Throwable e) { + LOGGER.debug("Can't load class {}", className, e); + } + } + + // Handle @Repeatable annotations classes.addAll(getAnnotatedByRepeatedAnnotation(clazz)); - return sortedByClassName(classes); + return sortedByClassName(classes); } private Set> getAnnotatedByRepeatedAnnotation( @@ -107,8 +203,19 @@ private Set> getAnnotatedByRepeatedAnnotation( Repeatable repeatableAnnotation = annotationClass .getAnnotation(Repeatable.class); if (repeatableAnnotation != null) { - return reflections - .getTypesAnnotatedWith(repeatableAnnotation.value(), true); + Set> classes = new LinkedHashSet<>(); + Set classNames = annotatedClassCache.getOrDefault( + repeatableAnnotation.value().getName(), + Collections.emptySet()); + + for (String className : classNames) { + try { + classes.add(classLoader.loadClass(className)); + } catch (Throwable e) { + LOGGER.debug("Can't load class {}", className, e); + } + } + return classes; } return Collections.emptySet(); } @@ -120,16 +227,22 @@ public URL getResource(String name) { @Override public boolean shouldInspectClass(String className) { - if (!reflections - .get(PackageScanner.INSTANCE - .of(PackageScanner.extractPackageName(className))) - .isEmpty()) { + String packageName = extractPackageName(className); + if (scannedPackages.contains(packageName)) { return classLoader.getResource( className.replace('.', '/') + ".class") != null; } return false; } + private static String extractPackageName(String className) { + int dot = className.lastIndexOf('.'); + if (dot != -1) { + return className.substring(0, dot); + } + return ""; + } + @SuppressWarnings("unchecked") @Override public Class loadClass(String name) throws ClassNotFoundException { @@ -138,7 +251,22 @@ public Class loadClass(String name) throws ClassNotFoundException { @Override public Set> getSubTypesOf(Class type) { - return sortedByClassName(reflections.getSubTypesOf(type)); + Set subtypeNames = subtypeCache.getOrDefault(type.getName(), + Collections.emptySet()); + + Set> classes = new LinkedHashSet<>(); + for (String className : subtypeNames) { + try { + @SuppressWarnings("unchecked") + Class clazz = (Class) classLoader + .loadClass(className); + classes.add(clazz); + } catch (Throwable e) { + LOGGER.debug("Can't load class {}", className, e); + } + } + + return sortedByClassName(classes); } @Override @@ -152,109 +280,60 @@ private Set> sortedByClassName( .collect(Collectors.toCollection(LinkedHashSet::new)); } - private static class LoggingReflections extends Reflections { - - LoggingReflections(Configuration configuration) { - super(configuration); - } - - // Classloading errors may cause the frontend-build to fail, but - // without any useful information. - // Copy-pasting the super method, with addition of exception logging - // to help in troubleshooting build issues - @Override - public Class forClass(String typeName, ClassLoader... loaders) { - if (primitiveNames.contains(typeName)) { - return primitiveTypes.get(primitiveNames.indexOf(typeName)); - } else { - String type; - if (typeName.contains("[")) { - int i = typeName.indexOf("["); - type = typeName.substring(0, i); - String array = typeName.substring(i).replace("]", ""); - if (primitiveNames.contains(type)) { - type = primitiveDescriptors - .get(primitiveNames.indexOf(type)); - } else { - type = "L" + type + ";"; - } - type = array + type; - } else { - type = typeName; - } + /** + * Builds cache of all classes grouped by their annotations. Maps annotation + * class name -> Set of annotated class names + */ + private Map> buildAnnotatedClassCache( + ClassInfoList allClasses) { + Map> cache = new HashMap<>(); - for (ClassLoader classLoader : ClasspathHelper - .classLoaders(loaders)) { - if (type.contains("[")) { - try { - return Class.forName(type, false, classLoader); - } catch (Throwable ignored) { - LOGGER.debug("Can't find class {}", type, ignored); - } - } - try { - return classLoader.loadClass(type); - } catch (Throwable ignored) { - LOGGER.debug("Can't load class {}", type, ignored); - } - } - return null; + // Get all classes with any annotation + for (ClassInfo classInfo : allClasses) { + for (AnnotationInfo annotationInfo : classInfo + .getAnnotationInfo()) { + String annotationName = annotationInfo.getName(); + cache.computeIfAbsent(annotationName, + k -> new LinkedHashSet<>()).add(classInfo.getName()); } } - } - - private static final Vfs.UrlType IGNORE_NOT_HANDLED_FILES = new Vfs.UrlType() { - - public boolean matches(URL url) { - // This handler is the last one to be checked. - // Valid "file:" URLs should have already been handled by default - // URL type handlers. - return "file".equals(url.getProtocol()); - } - public Vfs.Dir createDir(final URL url) { - LOGGER.debug( - "Class finder cannot scan {} URL. Probably pointing to a not existing folder.", - url); - return new Vfs.Dir() { - @Override - public String getPath() { - return url.getPath().replace("\\", "/"); - } + return cache; + } - @Override - public Iterable getFiles() { - return Collections.emptyList(); - } - }; - } - }; + /** + * Builds cache of all subtype relationships. Maps parent class/interface + * name -> Set of subclass/implementor names + */ + private Map> buildSubtypeCache( + ClassInfoList allClasses) { + Map> cache = new HashMap<>(); - private static class PackageScanner - implements Scanner, QueryBuilder, NameHelper { + // For each scanned class, register it under all its supertypes + for (ClassInfo classInfo : allClasses) { + String className = classInfo.getName(); - private final static PackageScanner INSTANCE = new PackageScanner(); + // Register under all superclasses + ClassInfoList superclasses = classInfo.getSuperclasses(); + for (ClassInfo superclass : superclasses) { + cache.computeIfAbsent(superclass.getName(), + k -> new LinkedHashSet<>()).add(className); + } - @Override - public List> scan(ClassFile classFile) { - String packageName = extractPackageName(classFile.getName()); - if (!packageName.isEmpty()) { - return List.of(entry(packageName, packageName)); + // Register under all implemented interfaces + ClassInfoList interfaces = classInfo.getInterfaces(); + for (ClassInfo iface : interfaces) { + cache.computeIfAbsent(iface.getName(), + k -> new LinkedHashSet<>()).add(className); } - return List.of(); } - @Override - public String index() { - return "PackageScanner"; - } + return cache; + } - static String extractPackageName(String className) { - int dot = className.lastIndexOf('.'); - if (dot != -1) { - return className.substring(0, dot); - } - return ""; + private void applyScannerPackageFilters(ClassGraph classGraph) { + if (!Boolean.getBoolean(DISABLE_DEFAULT_PACKAGE_FILTER)) { + classGraph.rejectPackages(DEFAULT_REJECTED_PACKAGES); } } } diff --git a/flow-plugins/flow-plugin-base/src/test/java/com/vaadin/flow/server/scanner/ReflectionsClassFinderTest.java b/flow-plugins/flow-plugin-base/src/test/java/com/vaadin/flow/server/scanner/ReflectionsClassFinderTest.java index fa96002eb07..1eea9b3dc82 100644 --- a/flow-plugins/flow-plugin-base/src/test/java/com/vaadin/flow/server/scanner/ReflectionsClassFinderTest.java +++ b/flow-plugins/flow-plugin-base/src/test/java/com/vaadin/flow/server/scanner/ReflectionsClassFinderTest.java @@ -25,6 +25,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Arrays; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -34,12 +35,6 @@ import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; -import org.mockito.ArgumentMatchers; -import org.mockito.MockedStatic; -import org.mockito.Mockito; -import org.reflections.Reflections; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.dependency.NpmPackage; @@ -92,6 +87,42 @@ public void getSubTypesOf_orderIsDeterministic() { Assert.assertEquals(a2, a3); } + @Test + public void getSubTypesOf_rejectNotVaadinKnownPackages() throws Exception { + urls = Arrays.copyOf(urls, urls.length + 1); + urls[urls.length - 1] = createTestModule("module-4", + "org.springframework.feature.ui", "SpringUIComponent", "2.0.0"); + Set result = new ReflectionsClassFinder(urls) + .getSubTypesOf(Component.class).stream().map(Class::getName) + .collect(Collectors.toSet()); + Assert.assertFalse( + "Classes from know not-UI packages should be rejected by default", + result.contains( + "org.springframework.feature.ui.SpringUIComponent")); + } + + @Test + public void getSubTypesOf_defaultRejectDisabled_scansAllPackages() + throws Exception { + urls = Arrays.copyOf(urls, urls.length + 1); + urls[urls.length - 1] = createTestModule("module-4", + "org.springframework.feature.ui", "SpringUIComponent", "2.0.0"); + System.setProperty( + ReflectionsClassFinder.DISABLE_DEFAULT_PACKAGE_FILTER, "true"); + try { + Set result = new ReflectionsClassFinder(urls) + .getSubTypesOf(Component.class).stream().map(Class::getName) + .collect(Collectors.toSet()); + Assert.assertTrue( + "Classes from know not-UI packages should be found when default rejection is disabled", + result.contains( + "org.springframework.feature.ui.SpringUIComponent")); + } finally { + System.clearProperty( + ReflectionsClassFinder.DISABLE_DEFAULT_PACKAGE_FILTER); + } + } + @Test public void getAnnotatedClasses_orderIsDeterministic() { List a1 = toList(new ReflectionsClassFinder(urls) @@ -125,27 +156,62 @@ public void getAnnotatedClasses_order_sameAsDefaultClassFinder() { } @Test - public void reflections_notExistingDirectory_warningMessageNotLogged() - throws Exception { + public void notExistingDirectory_noExceptionThrown() throws Exception { Path notExistingDir = Files.createTempDirectory("test") .resolve(Path.of("target", "classes")); - Logger logger = LoggerFactory.getLogger("mockLogger"); - Logger spy = Mockito.spy(logger); - Logger mocked = Mockito.mock(Logger.class); - try (MockedStatic mockStatic = Mockito - .mockStatic(LoggerFactory.class)) { - mockStatic - .when(() -> LoggerFactory - .getLogger(ArgumentMatchers.any(Class.class))) - .thenReturn(mocked); - mockStatic.when(() -> LoggerFactory.getLogger(Reflections.class)) - .thenReturn(spy); - Logger x = LoggerFactory.getLogger(ReflectionsClassFinder.class); - new ReflectionsClassFinder(notExistingDir.toUri().toURL()); - Mockito.verify(spy, Mockito.never()).warn(ArgumentMatchers.contains( - "could not create Vfs.Dir from url. ignoring the exception and continuing"), - ArgumentMatchers.any(Exception.class)); - } + // ClassGraph should handle non-existing directories gracefully + ReflectionsClassFinder finder = new ReflectionsClassFinder( + notExistingDir.toUri().toURL()); + // Verify scan completed successfully (returns empty set, not null) + Set> result = finder.getAnnotatedClasses(NpmPackage.class); + Assert.assertNotNull( + "Scan should complete even with non-existing directory", + result); + } + + @Test + public void getAnnotatedClasses_findsRepeatableAnnotations() + throws Exception { + // When a @Repeatable annotation is used multiple times, Java wraps + // them in the container annotation. The finder should detect this and + // find classes annotated with the container when searching for the + // repeatable annotation. + URL moduleUrl = createRepeatableAnnotationTestModule(); + ClassLoader classLoader = new URLClassLoader(new URL[] { moduleUrl }, + Thread.currentThread().getContextClassLoader()); + ReflectionsClassFinder finder = new ReflectionsClassFinder(classLoader, + moduleUrl); + + Set> result = finder.getAnnotatedClasses(NpmPackage.class); + + Class testClass = classLoader.loadClass( + "com.vaadin.flow.test.repeatable.ComponentWithMultipleNpmPackages"); + + Assert.assertTrue( + "Should find class with repeatable NpmPackage annotations through container", + result.contains(testClass)); + } + + private URL createRepeatableAnnotationTestModule() throws IOException { + String pkg = "com.vaadin.flow.test.repeatable"; + String className = "ComponentWithMultipleNpmPackages"; + String classSource = String.format("package %s;\n\n" + + "import com.vaadin.flow.component.dependency.NpmPackage;\n" + + "import com.vaadin.flow.component.Component;\n\n" + + "@NpmPackage(value = \"@vaadin/test-package-1\", version = \"1.0.0\")\n" + + "@NpmPackage(value = \"@vaadin/test-package-2\", version = \"2.0.0\")\n" + + "@NpmPackage(value = \"@vaadin/test-package-3\", version = \"3.0.0\")\n" + + "public class %s extends Component {\n}\n", pkg, className); + + File sources = externalModules.newFolder("repeatable-test/src"); + File sourcePkg = externalModules + .newFolder("repeatable-test/src/" + pkg.replace('.', '/')); + File buildDir = externalModules.newFolder("repeatable-test/target"); + + Path sourceFile = sourcePkg.toPath().resolve(className + ".java"); + Files.writeString(sourceFile, classSource, StandardCharsets.UTF_8); + compile(sourceFile.toFile(), sources, buildDir); + return buildDir.toURI().toURL(); } private > List toList(Set classes) {