From f949b1591184a36b6aaab88de150daa3e9ff5d71 Mon Sep 17 00:00:00 2001 From: Petter Holmstrom Date: Tue, 10 Nov 2026 09:09:09 +0200 Subject: [PATCH 1/3] Put classes that use test libraries in the test sources The Add Tests step now adds BrowserlessContextConfiguration, which uses the browserless test library but not JUnit. The generator put it in the main sources, where it doesn't compile. Co-Authored-By: Claude Opus 5.5 --- .github/steps/GenerateSteps.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/steps/GenerateSteps.java b/.github/steps/GenerateSteps.java index 71e5c56..a9f4377 100644 --- a/.github/steps/GenerateSteps.java +++ b/.github/steps/GenerateSteps.java @@ -51,6 +51,9 @@ public class GenerateSteps { static final Pattern GROUP_ID = Pattern.compile("([^<]+)"); static final Pattern ARTIFACT_ID = Pattern.compile("([^<]+)"); static final Pattern PACKAGE = Pattern.compile("(?m)^package ([\\w.]+);"); + // Packages of the test-scoped dependencies. A class that imports them only compiles in the test sources. + static final Pattern TEST_IMPORT = Pattern.compile("(?m)^import (static )?(org\\.junit|org\\.mockito|org\\.assertj|org\\.hamcrest" + + "|org\\.springframework\\.boot\\.test|org\\.springframework\\.test|com\\.vaadin\\.browserless|com\\.vaadin\\.testbench)\\."); // The Set Up Project step tells the reader to save the SQL files in this directory. static final String SQL_DIR = "src/main/resources"; @@ -368,8 +371,9 @@ static List listings(List lines, boolean titled) { return listings; } - // A Java class goes in the directory of its package, and a test in the test sources. Any - // other file replaces the only file of the same name in the project. + // A Java class goes in the directory of its package, in the test sources if it uses a test + // library, such as a test or the browserless application context of the tests. Any other file + // replaces the only file of the same name in the project. static Path targetFile(Listing listing, Path project) throws IOException { if (listing.fileName().endsWith(".java")) { var className = listing.fileName().replaceFirst("\\.java$", ""); @@ -380,7 +384,7 @@ static Path targetFile(Listing listing, Path project) throws IOException { if (!packageName.find()) { throw new IllegalStateException(listing.fileName() + " has no package"); } - var sources = listing.code().contains("import org.junit.") ? "src/test/java" : "src/main/java"; + var sources = TEST_IMPORT.matcher(listing.code()).find() ? "src/test/java" : "src/main/java"; return project.resolve(sources).resolve(packageName.group(1).replace('.', '/')).resolve(listing.fileName()); } try (Stream files = Files.walk(project)) { From 81f3684f9b57f1d6e5eff83e5f04d6b2b5af2bf3 Mon Sep 17 00:00:00 2001 From: Petter Holmstrom Date: Tue, 10 Nov 2026 09:09:09 +0200 Subject: [PATCH 2/3] Update main to the current tutorial The browserless tests now use the browserless application context, the skeleton moves the app shell to a class of its own, and the versions are Vaadin 25.3.5 and Spring Boot 4.1.2. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- pom.xml | 4 +- src/main/java/com/example/AppShell.java | 12 ++++ src/main/java/com/example/Application.java | 10 +--- .../BrowserlessContextConfiguration.java | 18 ++++++ .../product/ProductCatalogViewTest.java | 60 +++++++++++-------- 6 files changed, 70 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/example/AppShell.java create mode 100644 src/test/java/com/example/BrowserlessContextConfiguration.java diff --git a/README.md b/README.md index 9be2e59..897ed37 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ The application is a Spring Boot application, with an in-memory H2 database and - [`com.example.product`](src/main/java/com/example/product) holds the product catalog: the view, its form and dialog, the application service, and the repositories. The classes are package-private, since nothing outside the package uses them. - [`com.example.base.ui`](src/main/java/com/example/base/ui) holds the main layout and the title bar that the views share. - [`com.example.order`](src/main/java/com/example/order) holds the placeholder view for orders. -- [`src/test/java/com/example/product`](src/test/java/com/example/product) holds a test of the service, and browserless tests of the view. +- [`src/test/java/com/example/product`](src/test/java/com/example/product) holds a test of the service, and browserless tests of the view. The browserless application context that the view tests use is configured in [`BrowserlessContextConfiguration`](src/test/java/com/example/BrowserlessContextConfiguration.java). - [`schema.sql`](src/main/resources/schema.sql) and [`data.sql`](src/main/resources/data.sql) create the database and its sample data when the application starts. ## Step Branches diff --git a/pom.xml b/pom.xml index f27aaf5..d601a56 100644 --- a/pom.xml +++ b/pom.xml @@ -8,13 +8,13 @@ 21 - 25.3.2 + 25.3.5 org.springframework.boot spring-boot-starter-parent - 4.1.1 + 4.1.2 diff --git a/src/main/java/com/example/AppShell.java b/src/main/java/com/example/AppShell.java new file mode 100644 index 0000000..be66d6e --- /dev/null +++ b/src/main/java/com/example/AppShell.java @@ -0,0 +1,12 @@ +package com.example; + +import com.vaadin.flow.component.dependency.StyleSheet; +import com.vaadin.flow.component.page.AppShellConfigurator; +import com.vaadin.flow.component.page.Push; +import com.vaadin.flow.theme.aura.Aura; + +@StyleSheet(Aura.STYLESHEET) +@StyleSheet("styles.css") // Your custom styles +@Push +public class AppShell implements AppShellConfigurator { +} diff --git a/src/main/java/com/example/Application.java b/src/main/java/com/example/Application.java index 0b37f3d..9086057 100644 --- a/src/main/java/com/example/Application.java +++ b/src/main/java/com/example/Application.java @@ -1,18 +1,10 @@ package com.example; -import com.vaadin.flow.theme.aura.Aura; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import com.vaadin.flow.component.dependency.StyleSheet; -import com.vaadin.flow.component.page.AppShellConfigurator; -import com.vaadin.flow.component.page.Push; - @SpringBootApplication -@StyleSheet(Aura.STYLESHEET) -@StyleSheet("styles.css") // Your custom styles -@Push -public class Application implements AppShellConfigurator { +public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); diff --git a/src/test/java/com/example/BrowserlessContextConfiguration.java b/src/test/java/com/example/BrowserlessContextConfiguration.java new file mode 100644 index 0000000..77557ad --- /dev/null +++ b/src/test/java/com/example/BrowserlessContextConfiguration.java @@ -0,0 +1,18 @@ +package com.example; + +import com.vaadin.browserless.BrowserlessApplicationContext; +import com.vaadin.browserless.SpringBrowserlessApplicationContext; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class BrowserlessContextConfiguration { + + @Bean + BrowserlessApplicationContext browserlessApplicationContext( + ApplicationContext applicationContext) { + return SpringBrowserlessApplicationContext.create(applicationContext, + Application.class); + } +} diff --git a/src/test/java/com/example/product/ProductCatalogViewTest.java b/src/test/java/com/example/product/ProductCatalogViewTest.java index 8064db4..3cc2961 100644 --- a/src/test/java/com/example/product/ProductCatalogViewTest.java +++ b/src/test/java/com/example/product/ProductCatalogViewTest.java @@ -1,12 +1,11 @@ package com.example.product; -import com.vaadin.browserless.SpringBrowserlessTest; -import com.vaadin.flow.component.button.Button; -import com.vaadin.flow.component.grid.Grid; -import com.vaadin.flow.component.notification.Notification; -import com.vaadin.flow.component.textfield.BigDecimalField; -import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.browserless.BrowserlessApplicationContext; +import com.vaadin.browserless.BrowserlessUIContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.math.BigDecimal; @@ -15,42 +14,55 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @SpringBootTest -class ProductCatalogViewTest extends SpringBrowserlessTest { +class ProductCatalogViewTest { + + @Autowired + private BrowserlessApplicationContext app; + + private BrowserlessUIContext window; + + @BeforeEach + void openWindow() { + window = app.newUser().newWindow(); + } + + @AfterEach + void closeUser() { + window.getUser().close(); + } @Test void selectRow_showsProductInDrawer() { - navigate(ProductCatalogView.class); - var grid = test(find(Grid.class).single(), - ProductCatalogItem.class); + window.navigate(ProductCatalogView.class); + var grid = window.findGrid(ProductCatalogItem.class); grid.select(0); - var nameField = find(TextField.class).withLabel("Name").single(); - assertEquals(grid.getRow(0).name(), nameField.getValue()); + var nameField = window.findTextField().withLabel("Name"); + assertEquals(grid.getRow(0).name(), nameField.component().getValue()); } @Test void saveNegativePrice_showsErrorMessage() { - navigate(ProductCatalogView.class, 1L); - var priceField = find(BigDecimalField.class).withLabel("Price").single(); + window.navigate(ProductCatalogView.class, 1L); + var priceField = window.findBigDecimalField().withLabel("Price"); - test(priceField).setValue(new BigDecimal("-1.00")); - test(find(Button.class).withText("Save").single()).click(); + priceField.setValue(new BigDecimal("-1.00")); + window.findButton().withText("Save").click(); - assertTrue(priceField.isInvalid()); - assertEquals("Enter a price of 0 or more", priceField.getErrorMessage()); + assertTrue(priceField.component().isInvalid()); + assertEquals("Enter a price of 0 or more", + priceField.component().getErrorMessage()); } @Test void saveDuplicateSku_showsNotification() { - navigate(ProductCatalogView.class, 1L); - var skuField = find(TextField.class).withLabel("SKU").single(); + window.navigate(ProductCatalogView.class, 1L); - test(skuField).setValue("NBK-A5-DOT"); - test(find(Button.class).withText("Save").single()).click(); + window.findTextField().withLabel("SKU").setValue("NBK-A5-DOT"); + window.findButton().withText("Save").click(); - var notification = find(Notification.class).single(); assertEquals("The SKU is already in use. Please enter another one.", - test(notification).getText()); + window.findNotification().getText()); } } From 8c9cb4bf9b6e6b9ceb95df6e88980cdf8b051732 Mon Sep 17 00:00:00 2001 From: Petter Holmstrom Date: Tue, 10 Nov 2026 09:12:53 +0200 Subject: [PATCH 3/3] Open a pull request when main drifts from the tutorial Every Vaadin maintenance release changes the project skeleton, so main drifts from the last step almost every week. Instead of only failing the check, CI now opens or updates a pull request from the update-main branch that updates main, and closes it when main matches again. Since GitHub doesn't run workflows for pull requests that a workflow opens, CI runs on the branch through a manual workflow run. Co-Authored-By: Claude Opus 5.5 --- .github/steps/update-main.sh | 87 ++++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 31 ++++++++++++- README.md | 15 ++++--- 3 files changed, 127 insertions(+), 6 deletions(-) create mode 100755 .github/steps/update-main.sh diff --git a/.github/steps/update-main.sh b/.github/steps/update-main.sh new file mode 100755 index 0000000..14925c3 --- /dev/null +++ b/.github/steps/update-main.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# +# Opens a pull request that updates main to the last step that GenerateSteps.java generated, if +# main doesn't match it. The update replaces every file except README.md and the .github +# directory, and removes the files that the last step doesn't have. The pull request comes from +# the update-main branch. If main already matches, the script closes an open pull request from +# that branch. +# +# Usage: update-main.sh [--push] +# +# Run it in a clone of vaadin/tutorial, with main checked out. Without --push, it only prints the +# changes. With --push, it also needs the GitHub CLI, and permission to push, to open pull +# requests, and to run workflows. Set DOCS_COMMIT to the vaadin/docs commit that the steps were +# generated from, to mention it in the commit message and the pull request. + +set -euo pipefail + +steps=$1 +push=${2:-} +remote=origin +branch=update-main +title="Update main to the current tutorial" + +last=$(tail -n 1 "$steps/steps.tsv" | cut -f1) +git_dir=$(git rev-parse --absolute-git-dir) +index=$(mktemp) +trap 'rm -f "$index"' EXIT + +rm -f "$index" +GIT_INDEX_FILE=$index git read-tree HEAD +# Run git in the step's directory, so that the pathspecs refer to it. +(cd "$steps/$last" && GIT_INDEX_FILE=$index git -c core.autocrlf=false --git-dir="$git_dir" --work-tree=. \ + add --all -- . ':!README.md' ':!.github') +tree=$(GIT_INDEX_FILE=$index git write-tree) + +if [[ $tree == $(git rev-parse "HEAD^{tree}") ]]; then + echo "main matches the $last step." + if [[ $push == --push ]]; then + for pr in $(gh pr list --head "$branch" --base main --state open --json number --jq '.[].number'); do + gh pr close "$pr" --delete-branch --comment "main matches the tutorial again, so this pull request is no longer needed." + done + fi + exit 0 +fi + +echo "main doesn't match the $last step:" +git diff --stat HEAD "$tree" + +existing=$(git ls-remote "$remote" "refs/heads/$branch" | cut -f1) +if [[ -n $existing ]]; then + git fetch --quiet "$remote" "refs/heads/$branch" + if [[ $(git rev-parse "$existing^{tree}") == "$tree" && $(git rev-parse "$existing^") == $(git rev-parse HEAD) ]]; then + echo "The $branch branch already has these changes." + exit 0 + fi +fi + +message="The application at the end of the last step of the Vaadin tutorial." +if [[ -n ${DOCS_COMMIT:-} ]]; then + message+=$'\n\n'"Generated from vaadin/docs@$DOCS_COMMIT." +fi +commit=$(git commit-tree "$tree" -p HEAD -m "$title" -m "$message") +echo "$branch: $commit" + +if [[ $push != --push ]]; then + echo "Not pushing. Run with --push to update the $branch branch and its pull request." + exit 0 +fi + +git push "$remote" "+$commit:refs/heads/$branch" + +body="The tutorial in [vaadin/docs](https://github.com/vaadin/docs/tree/${DOCS_COMMIT:-main}/articles/getting-started/tutorial) has changed, so \`main\` no longer matches the application at the end of its last step. This pull request updates \`main\` to that application, as CI generated it. It doesn't change \`README.md\` or \`.github\`. If a step was added to the tutorial, or removed from it, update the table of step branches in \`README.md\` before you merge. + +\`\`\` +$(git diff --stat HEAD "$commit") +\`\`\` + +GitHub doesn't run workflows for pull requests that a workflow opens, so CI runs on the \`$branch\` branch through a manual workflow run instead. Its checks appear on this pull request. + +This pull request was opened by the CI workflow. It updates the pull request when the tutorial changes again, and closes it when \`main\` matches the tutorial." +pr=$(gh pr list --head "$branch" --base main --state open --json number --jq '.[0].number // empty') +if [[ -n $pr ]]; then + gh pr edit "$pr" --title "$title" --body "$body" +else + gh pr create --head "$branch" --base main --title "$title" --body "$body" +fi +gh workflow run ci.yml --ref "$branch" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 771b0e5..707f2ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,7 +83,7 @@ jobs: export GIT_INDEX_FILE=$RUNNER_TEMP/index git -c core.autocrlf=false --work-tree="build/steps/$last" add --all if ! git diff --cached --exit-code HEAD -- . ':!README.md' ':!.github'; then - echo "::error::main doesn't match the $last step of the tutorial. See Maintenance in README.md." + echo "::error::This doesn't match the $last step of the tutorial. On main, CI opens a pull request from the update-main branch to fix that. See Maintenance in README.md." exit 1 fi - name: Check that the README lists every step branch @@ -142,3 +142,32 @@ jobs: GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com DOCS_COMMIT: ${{ needs.generate.outputs.docs-commit }} run: .github/steps/commit-steps.sh build/steps --push + + update-main: + name: Open a pull request to update main + needs: [generate, verify] + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + concurrency: update-main + permissions: + contents: write + pull-requests: write + # To run CI on the pull request's branch, since GitHub doesn't run + # workflows for pull requests that a workflow opens + actions: write + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 + with: + name: steps + path: build + - run: tar -xf build/steps.tar -C build + - name: Open or update the pull request if main doesn't match the last step + env: + GH_TOKEN: ${{ github.token }} + GIT_AUTHOR_NAME: github-actions[bot] + GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + DOCS_COMMIT: ${{ needs.generate.outputs.docs-commit }} + run: .github/steps/update-main.sh build/steps --push diff --git a/README.md b/README.md index 897ed37..455099e 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,15 @@ The [CI workflow](.github/workflows/ci.yml) runs on every push to `main`, on eve - builds `main` and runs its tests, - generates the steps from the branch of vaadin/docs in `DOCS_REF`, and builds and tests each of them with `./mvnw verify`, -- checks that `main` matches the last step, except for this README and the `.github` directory, and that this README lists every step branch, and -- on `main`, commits each step that changed on top of the previous step, and force-pushes its branch, and the branches of the steps after it. +- checks that `main` matches the last step, except for this README and the `.github` directory, and that this README lists every step branch, +- on `main`, commits each step that changed on top of the previous step, and force-pushes its branch, and the branches of the steps after it, and +- on `main`, opens a pull request from the `update-main` branch that updates `main` to the last step, if they don't match. -To fix the code, fix the tutorial in vaadin/docs. The next CI run on `main` updates the step branches. If the last step changed, the check of `main` fails until you update `main` to match. To do that, generate the steps from the branch in `DOCS_REF`, here `v25.3`: +To fix the code, fix the tutorial in vaadin/docs. The next CI run on `main` updates the step branches. If the last step changed, it also opens a pull request that updates `main`, or updates the pull request that is already open. Review and merge it. Until then, the check of `main` fails. + +The pull request replaces every file except `README.md` and the `.github` directory, and removes the files that the last step doesn't have. If a step was added to the tutorial, or removed from it, update the table of step branches in this README in the same pull request. Its check fails until you do. GitHub doesn't run workflows for pull requests that a workflow opens, so CI runs on the `update-main` branch through a manual workflow run, whose checks appear on the pull request. If `main` matches the last step again, for example because someone updated it by hand, CI closes the pull request. + +To generate the steps yourself, for example to see what a change to the tutorial does, use the branch of vaadin/docs in `DOCS_REF`, here `v25.3`: ```bash git clone --depth 1 --branch v25.3 --filter=blob:none --sparse https://github.com/vaadin/docs.git ../docs @@ -73,9 +78,9 @@ git -C ../docs sparse-checkout set --no-cone /articles/_vaadin-version.adoc /art java .github/steps/GenerateSteps.java ../docs ../steps ``` -Then copy the files from `../steps/add-tests` over the files of this repository, except for `README.md`, and commit them. Add them with `git -c core.autocrlf=false add`, so that `mvnw.cmd` keeps the Windows line endings that it has in the project skeleton. +Then `.github/steps/update-main.sh ../steps` shows how `main` differs from the last step, and `.github/steps/commit-steps.sh ../steps` shows which step branches would change. Neither pushes anything unless you add `--push`. -When a new Vaadin version becomes the one at https://vaadin.com/docs/latest, set `DOCS_REF` in the CI workflow to the branch of vaadin/docs for that version, such as `v25.4`, and update `main` the same way. +When a new Vaadin version becomes the one at https://vaadin.com/docs/latest, set `DOCS_REF` in the CI workflow to the branch of vaadin/docs for that version, such as `v25.4`. The next CI run on `main` then opens a pull request that updates `main` to it. ## Other Versions