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)) {
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 9be2e59..455099e 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
@@ -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
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());
}
}