Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: automate generation of codestarts and update content #1279

Merged
merged 2 commits into from
Feb 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,15 +268,21 @@ mvn -DtrimStackTrace=false -Dmaven.surefire.debug -Pit-tests verify

## Update codestarts

The source code of the extension codestarts are built, using the Hilla application scaffold
utility (`HillaAppInitUtility`).
To update the source code, run the following command in the `runtime` and `runtime-react` folders,
and commit the changes.
The source code of the extension codestarts are built by downloading a project from start.vaadin.com and applying necessary updates and cleanup.
To update the source code, run the following command in the `lit/runtime` and `react/runtime` folders.

```terminal
mvn -Pupdate-hilla-codestart
```

Once the codestarts are updated, run the `integration-tests/codestart-tests` test modules, using the `-Dsnap` option to update the snapshot files.
```terminal
mvn clean verify -Dsnap
```

The tests generate projects into the `target` folder, that can be used for manual verifications.
Once verifications are completed, commit the changes.

## Release

The release process is based on the awesome [JReleaser](https://jreleaser.org/) tool.
Expand Down
260 changes: 260 additions & 0 deletions etc/CodestartUpdater.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
/// usr/bin/env jbang "$0" "$@" ; exit $?
//DEPS org.apache.maven.shared:maven-invoker:3.3.0
//DEPS info.picocli:picocli:4.6.3

//JAVA 17

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import org.apache.maven.shared.invoker.DefaultInvocationRequest;
import org.apache.maven.shared.invoker.DefaultInvoker;
import org.apache.maven.shared.invoker.InvocationRequest;
import org.apache.maven.shared.invoker.InvocationResult;
import org.apache.maven.shared.invoker.MavenInvocationException;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;

@Command(name = "CodestartUpdater", mixinStandardHelpOptions = true, version = "1.0",
description = "Updates Quarkus-Hilla code starts")
class CodestartUpdater implements Callable<Integer> {

private static final Pattern ZIP_EXCLUDES = Pattern.compile("^([^/]+/)(\\..*|mvnw.*|README\\.md|LICENSE\\.md|src/main/resources/(?!META-INF/resources/).*)$");

enum Preset {
REACT("partial-hilla-example-views"),
LIT("hilla");

private final String preset;

Preset(String preset) {
this.preset = preset;
}
}

@CommandLine.Spec
CommandLine.Model.CommandSpec spec;

@Parameters(index = "0", description = "Hilla preset for code start generation")
private Preset preset;

@Parameters(index = "1", description = "Base path for the codestart resources")
private Path codestartPath;

@Option(names = {"-p", "--pre-releases"},
defaultValue = "false",
description = "Use Hilla pre release.")
private boolean preReleses;


@Option(names = {"-m", "--maven-home"}, description = "Maven HOME path")
private Path mavenHome;

@Option(names = {"-v", "--verbose"},
defaultValue = "false",
description = "Print debug information.")
private boolean verbose;

public static void main(String... args) {
int exitCode = new CommandLine(new CodestartUpdater()).execute(args);
System.exit(exitCode);
}

@Override
public Integer call() throws Exception {
if (!Files.isDirectory(codestartPath)) {
throw new CommandLine.ParameterException(spec.commandLine(), "Base codestart path is not an existing directory");
}
if (mavenHome != null && !Files.isDirectory(mavenHome)) {
throw new CommandLine.ParameterException(spec.commandLine(), "Maven HOME is not an existing directory: " + mavenHome);
}
info("Updating Codestart " + codestartPath + " with " + preset + " preset");
Path extractPath = downloadAndExtract("partial-hilla-example-views");
updateJavaFiles(extractPath);
updateCodestart(extractPath);
deleteAllFiles(extractPath, null);
info("Update completed");
return 0;
}

private void deleteAllFiles(Path pathToBeDeleted, Predicate<Path> skip) throws IOException {
if (Files.isDirectory(pathToBeDeleted)) {
debug("Cleaning up " + pathToBeDeleted);
Files.walkFileTree(pathToBeDeleted,
new SimpleFileVisitor<>() {
@Override
public FileVisitResult postVisitDirectory(
Path dir, IOException exc) throws IOException {
if (Files.list(dir).toList().isEmpty()) {
Files.delete(dir);
}
return FileVisitResult.CONTINUE;
}

@Override
public FileVisitResult visitFile(
Path file, BasicFileAttributes attrs)
throws IOException {
if (skip == null || !skip.test(file)) {
Files.delete(file);
}
return FileVisitResult.CONTINUE;
}
});
}
}


private Path downloadAndExtract(String presets) throws IOException {
if (preReleses) {
presets += ",partial-prerelease";
}
String appFolderName = "qh-codestart";
URL url = new URL(String.format("https://start.vaadin.com/dl?preset=base,%s&projectName=%s", presets, appFolderName));
info("Downloading template application from " + url);
Path tempFile = Files.createTempFile(appFolderName, ".zip");
try (var stream = url.openStream()) {
Files.copy(stream, tempFile, StandardCopyOption.REPLACE_EXISTING);
}
Path tempDirectory = Files.createTempDirectory(appFolderName);
try (InputStream fis = Files.newInputStream(tempFile); ZipInputStream zis = new ZipInputStream(fis)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
Path target = tempDirectory.resolve(entry.getName()).normalize(); // Prevent Zip Slip
if (!target.startsWith(tempDirectory)) {
throw new IOException("Invalid zip entry: " + entry.getName()); // Protect against Zip Slip
}
String entryName = entry.getName();
Matcher matcher = ZIP_EXCLUDES.matcher(entryName);
if (matcher.matches()) {
debug("Ignoring zip entry: " + entryName);
} else if (entry.isDirectory()) {
Files.createDirectories(target);
} else {
debug("Extracting zip entry " + entryName + " to " + target);
Files.createDirectories(target.getParent());
Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING);
}
zis.closeEntry();
}
}
return tempDirectory.resolve(appFolderName);
}

private void updateCodestart(Path extractPath) throws IOException {
info("Updating codestart files...");
Path javaFolder = codestartPath.resolve("java");
Path baseFolder = codestartPath.resolve(Path.of("base"));
deleteAllFiles(javaFolder, null);
deleteAllFiles(baseFolder, path ->
path.getFileName().toString().contains(".tpl.qute"));
Path relativeFrontendFolder = Path.of("src", "main", "frontend");
Path relativeJavaFolder = Path.of("src", "main", "java");
Files.walkFileTree(extractPath, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Path relativePath = extractPath.relativize(file);
if (relativePath.startsWith(relativeJavaFolder)) {
debug("Copying Java file: " + relativePath + " to " + javaFolder);
Path target = javaFolder.resolve(relativePath);
Files.createDirectories(target.getParent());
Files.copy(file, target);
} else {
debug("Copying Base file: " + relativePath + " to " + baseFolder);
Path target = baseFolder.resolve(relativePath);
Files.createDirectories(target.getParent());
Files.copy(file, target);
}
return FileVisitResult.CONTINUE;
}
});
info("Updating codestart files...");
}

private void updateJavaFiles(Path projectFolder) throws IOException {
info("Updating Java files...");
Path openrewriteRecipe = projectFolder.resolve("rewrite.yml");
Files.writeString(openrewriteRecipe, RECIPE);
InvocationRequest request = new DefaultInvocationRequest();
Path pomFile = projectFolder.resolve("pom.xml");
if (mavenHome != null) {
request.setMavenHome(mavenHome.toFile());
}
request.setBatchMode(true);
request.setNoTransferProgress(true);
request.setPomFile(pomFile.toFile());
request.setQuiet(!verbose);
request.setBaseDirectory(projectFolder.toFile());
request.addArgs(List.of(
"org.openrewrite.maven:rewrite-maven-plugin:runNoFork",
"-Drewrite.activeRecipes=com.github.mcollovati.quarkus.hilla.UpdateCodestart"
));
DefaultInvoker invoker = new DefaultInvoker();
InvocationResult result = null;
try {
result = invoker.execute(request);
} catch (MavenInvocationException e) {
throw new IOException(e);
}
int exitCode = result.getExitCode();
if (exitCode != 0) {
String error = "Maven invocation failed with exit code " + exitCode + ".";
if (!verbose) {
error += " Rerun with -v for debug information.";
}
throw new IOException(error);
}
Files.deleteIfExists(pomFile);
Files.deleteIfExists(openrewriteRecipe);
}

private void info(String message) {
System.out.println(message);
}

private void debug(String message) {
if (verbose) {
System.err.println(message);
}
}

private static final String RECIPE = """
---
type: specs.openrewrite.org/v1beta/recipe
name: com.github.mcollovati.quarkus.hilla.UpdateCodestart
causesAnotherCycle: true
recipeList:
- org.openrewrite.java.ChangePackage:
oldPackageName: com.example.application
newPackageName: org.acme
recursive: true
- org.openrewrite.java.RemoveAnnotation:
annotationPattern: "@org.springframework.boot.autoconfigure.SpringBootApplication"
- org.openrewrite.java.RemoveAnnotation:
annotationPattern: "@org.springframework.stereotype.Service"
- org.openrewrite.java.RemoveMethodInvocations:
methodPattern: "org.springframework.boot.SpringApplication *(..)"
- org.openrewrite.text.FindAndReplace:
find: "(.*public class [^{]+)\\\\{.*public static void main\\\\(String\\\\[\\\\].*}\\r?\\n?$"
replace: "$1{\\n}"
regex: true
dotAll: true
filePattern: "**/Application.java"
""";
}
39 changes: 39 additions & 0 deletions integration-tests/codestart-tests/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.github.mcollovati</groupId>
<artifactId>quarkus-hilla-tests</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>

<artifactId>codestart-tests</artifactId>
<name>Quarkus - Hilla - Codestart Tests</name>

<properties>
<vaadin-maven-plugin.phase>none</vaadin-maven-plugin.phase>
</properties>


<dependencies>
<dependency>
<groupId>com.github.mcollovati</groupId>
<artifactId>quarkus-hilla</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.github.mcollovati</groupId>
<artifactId>quarkus-hilla-react</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-devtools-testing</artifactId>
<version>${quarkus.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright 2025 Marco Collovati, Dario Götze
*
* 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 com.github.mcollovati.quarkus.hilla.codestart;

import io.quarkus.devtools.testing.codestarts.QuarkusCodestartTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import static io.quarkus.devtools.codestarts.quarkus.QuarkusCodestartCatalog.Language.JAVA;

public class QuarkusHillaLitCodestartTest {

@RegisterExtension
public static QuarkusCodestartTest codestartTest = QuarkusCodestartTest.builder()
.languages(JAVA)
.setupStandaloneExtensionTest("com.github.mcollovati:quarkus-hilla")
.build();

@Test
void testContent() throws Throwable {
codestartTest.checkGeneratedSource("org.acme.Application");
codestartTest.checkGeneratedSource("org.acme.services.HelloWorldService");
codestartTest
.assertThatGeneratedFile(JAVA, "src/main/frontend/views/helloworld/hello-world-view.ts")
.exists()
.content()
.contains("await HelloWorldService.sayHello(");
codestartTest
.assertThatGeneratedFile(JAVA, "src/main/frontend/views/main-layout.ts")
.exists();
codestartTest
.assertThatGeneratedFile(JAVA, "src/main/frontend/routes.ts")
.exists();
codestartTest
.assertThatGeneratedFile(JAVA, "pom.xml")
.exists()
.content()
.contains("<artifactId>vaadin-bom</artifactId>");
codestartTest
.assertThatGeneratedFile(JAVA, "package.json")
.exists()
.content()
.contains("@vaadin/router")
.doesNotContain("@vaadin/hilla-file-router", "@vaadin/react-components", "@vaadin/hilla-react-signals");
codestartTest.assertThatGeneratedFile(JAVA, "package-lock.json").exists();
codestartTest.assertThatGeneratedFile(JAVA, "vite.config.ts").exists();
codestartTest.assertThatGeneratedFile(JAVA, "types.d.ts").exists();
codestartTest.assertThatGeneratedFile(JAVA, "tsconfig.json").exists();
}

@Test
void buildAllProjects() throws Throwable {
codestartTest.buildAllProjects();
}
}
Loading
Loading