Skip to content
Open
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
10 changes: 7 additions & 3 deletions .github/steps/GenerateSteps.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ public class GenerateSteps {
static final Pattern GROUP_ID = Pattern.compile("<groupId>([^<]+)</groupId>");
static final Pattern ARTIFACT_ID = Pattern.compile("<artifactId>([^<]+)</artifactId>");
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";
Expand Down Expand Up @@ -368,8 +371,9 @@ static List<Listing> listings(List<String> 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$", "");
Expand All @@ -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<Path> files = Files.walk(project)) {
Expand Down
87 changes: 87 additions & 0 deletions .github/steps/update-main.sh
Original file line number Diff line number Diff line change
@@ -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 <output-dir> [--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"
31 changes: 30 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -62,20 +62,25 @@ 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
git -C ../docs sparse-checkout set --no-cone /articles/_vaadin-version.adoc /articles/getting-started/tutorial/ /src/main/resources/META-INF/resources/vaadin/tutorial/
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

Expand Down
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@

<properties>
<java.version>21</java.version>
<vaadin.version>25.3.2</vaadin.version>
<vaadin.version>25.3.5</vaadin.version>
</properties>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<version>4.1.2</version>
<relativePath/>
</parent>

Expand Down
12 changes: 12 additions & 0 deletions src/main/java/com/example/AppShell.java
Original file line number Diff line number Diff line change
@@ -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 {
}
10 changes: 1 addition & 9 deletions src/main/java/com/example/Application.java
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
18 changes: 18 additions & 0 deletions src/test/java/com/example/BrowserlessContextConfiguration.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
60 changes: 36 additions & 24 deletions src/test/java/com/example/product/ProductCatalogViewTest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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());
}
}
Loading