From 6fa958c4adb88263a76c22b5a6854ac1b28dad21 Mon Sep 17 00:00:00 2001 From: Eric Handtke Date: Tue, 1 Sep 2026 13:31:33 +0200 Subject: [PATCH 1/9] enabled craftforj and hotreload --- 1-creating-a-basic-app/README.md | 2 +- 1-creating-a-basic-app/pom.xml | 37 ++++--------------- .../src/main/resources/application.properties | 4 +- .../src/main/resources/banner.txt | 2 +- 2-working-with-data/README.md | 2 +- 2-working-with-data/pom.xml | 37 ++++--------------- .../src/main/resources/application.properties | 2 +- .../src/main/resources/banner.txt | 2 +- 3-routing-and-composites/README.md | 2 +- 3-routing-and-composites/pom.xml | 36 ++++-------------- .../src/main/resources/application.properties | 2 +- .../src/main/resources/banner.txt | 2 +- 4-observers-and-route-parameters/README.md | 2 +- 4-observers-and-route-parameters/pom.xml | 36 ++++-------------- .../src/main/resources/application.properties | 2 +- .../src/main/resources/banner.txt | 2 +- 5-validating-and-binding-data/README.md | 2 +- 5-validating-and-binding-data/pom.xml | 36 ++++-------------- .../src/main/resources/application.properties | 2 +- .../src/main/resources/banner.txt | 2 +- 6-integrating-an-app-layout/README.md | 2 +- 6-integrating-an-app-layout/pom.xml | 36 ++++-------------- .../src/main/resources/application.properties | 2 +- .../src/main/resources/banner.txt | 2 +- README.md | 2 +- 25 files changed, 62 insertions(+), 196 deletions(-) diff --git a/1-creating-a-basic-app/README.md b/1-creating-a-basic-app/README.md index 1ff1ef8..6b97b67 100644 --- a/1-creating-a-basic-app/README.md +++ b/1-creating-a-basic-app/README.md @@ -12,7 +12,7 @@ For a complete step-by-step guide to building this app, see the **[webforJ Tutor To run the app, ensure the following tools are installed: - Java 21 or higher -- BBj 26.01 when running with local BBjServices +- BBj 26.02 when running with local BBjServices - Maven - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser diff --git a/1-creating-a-basic-app/pom.xml b/1-creating-a-basic-app/pom.xml index ea8ff7e..8c2a243 100644 --- a/1-creating-a-basic-app/pom.xml +++ b/1-creating-a-basic-app/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.7 + 4.0.8 com.webforj.tutorial @@ -14,7 +14,7 @@ CustomerApplication 21 - 26.01 + 26.02 21 21 UTF-8 @@ -39,16 +39,6 @@ org.springframework.boot spring-boot-starter-validation - - com.webforj - webforj-spring-devtools - true - - - org.springframework.boot - spring-boot-devtools - true - org.springframework.boot spring-boot-starter-test @@ -75,6 +65,11 @@ webforj-maven-plugin ${webforj.version} true + + + + + org.springframework.boot @@ -85,7 +80,6 @@ maven-failsafe-plugin - 3.5.6 @@ -104,23 +98,6 @@ prod - - - - org.springframework.boot - spring-boot-maven-plugin - - true - - - com.webforj - webforj-spring-devtools - - - - - - dev diff --git a/1-creating-a-basic-app/src/main/resources/application.properties b/1-creating-a-basic-app/src/main/resources/application.properties index 6c6b324..9ed58e8 100644 --- a/1-creating-a-basic-app/src/main/resources/application.properties +++ b/1-creating-a-basic-app/src/main/resources/application.properties @@ -6,11 +6,11 @@ server.shutdown=immediate server.port=8080 # webforJ DevTools configuration -spring.devtools.livereload.enabled=false webforj.devtools.livereload.enabled=true webforj.devtools.livereload.static-resources-enabled=true webforj.devtools.browser.open=true webforj.debug=true +webforj.devtools.craftforj.enabled=true webforj.entry = com.webforj.tutorial.Application # H2 Database configuration @@ -25,4 +25,4 @@ webforj.entry = com.webforj.tutorial.Application # H2 Console (optional - for development) # spring.h2.console.enabled=true -# spring.h2.console.path=/h2-console \ No newline at end of file +# spring.h2.console.path=/h2-console diff --git a/1-creating-a-basic-app/src/main/resources/banner.txt b/1-creating-a-basic-app/src/main/resources/banner.txt index db3c9dd..2e28361 100644 --- a/1-creating-a-basic-app/src/main/resources/banner.txt +++ b/1-creating-a-basic-app/src/main/resources/banner.txt @@ -4,4 +4,4 @@ \ V V / __/ | |_) | _| (_) | | _| | \_/\_/ \___| |_.__/|_| \___/|_| |___| - :: webforJ (26.01) :: + :: webforJ (26.02) :: diff --git a/2-working-with-data/README.md b/2-working-with-data/README.md index 1ff1ef8..6b97b67 100644 --- a/2-working-with-data/README.md +++ b/2-working-with-data/README.md @@ -12,7 +12,7 @@ For a complete step-by-step guide to building this app, see the **[webforJ Tutor To run the app, ensure the following tools are installed: - Java 21 or higher -- BBj 26.01 when running with local BBjServices +- BBj 26.02 when running with local BBjServices - Maven - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser diff --git a/2-working-with-data/pom.xml b/2-working-with-data/pom.xml index 39bc4e2..cb01674 100644 --- a/2-working-with-data/pom.xml +++ b/2-working-with-data/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.7 + 4.0.8 com.webforj.tutorial @@ -14,7 +14,7 @@ CustomerApplication 21 - 26.01 + 26.02 21 21 UTF-8 @@ -43,16 +43,6 @@ org.springframework.boot spring-boot-starter-validation - - com.webforj - webforj-spring-devtools - true - - - org.springframework.boot - spring-boot-devtools - true - org.springframework.boot spring-boot-starter-test @@ -83,6 +73,11 @@ webforj-maven-plugin ${webforj.version} true + + + + + org.springframework.boot @@ -93,7 +88,6 @@ maven-failsafe-plugin - 3.5.6 @@ -112,23 +106,6 @@ prod - - - - org.springframework.boot - spring-boot-maven-plugin - - true - - - com.webforj - webforj-spring-devtools - - - - - - dev diff --git a/2-working-with-data/src/main/resources/application.properties b/2-working-with-data/src/main/resources/application.properties index e582faf..872c0a9 100644 --- a/2-working-with-data/src/main/resources/application.properties +++ b/2-working-with-data/src/main/resources/application.properties @@ -6,11 +6,11 @@ server.shutdown=immediate server.port=8080 # webforJ DevTools configuration -spring.devtools.livereload.enabled=false webforj.devtools.livereload.enabled=true webforj.devtools.livereload.static-resources-enabled=true webforj.devtools.browser.open=true webforj.debug=true +webforj.devtools.craftforj.enabled=true webforj.entry = com.webforj.tutorial.Application # H2 Database configuration diff --git a/2-working-with-data/src/main/resources/banner.txt b/2-working-with-data/src/main/resources/banner.txt index db3c9dd..2e28361 100644 --- a/2-working-with-data/src/main/resources/banner.txt +++ b/2-working-with-data/src/main/resources/banner.txt @@ -4,4 +4,4 @@ \ V V / __/ | |_) | _| (_) | | _| | \_/\_/ \___| |_.__/|_| \___/|_| |___| - :: webforJ (26.01) :: + :: webforJ (26.02) :: diff --git a/3-routing-and-composites/README.md b/3-routing-and-composites/README.md index 1ff1ef8..6b97b67 100644 --- a/3-routing-and-composites/README.md +++ b/3-routing-and-composites/README.md @@ -12,7 +12,7 @@ For a complete step-by-step guide to building this app, see the **[webforJ Tutor To run the app, ensure the following tools are installed: - Java 21 or higher -- BBj 26.01 when running with local BBjServices +- BBj 26.02 when running with local BBjServices - Maven - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser diff --git a/3-routing-and-composites/pom.xml b/3-routing-and-composites/pom.xml index f9b5fac..cb01674 100644 --- a/3-routing-and-composites/pom.xml +++ b/3-routing-and-composites/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.7 + 4.0.8 com.webforj.tutorial @@ -14,7 +14,7 @@ CustomerApplication 21 - 26.01 + 26.02 21 21 UTF-8 @@ -43,16 +43,6 @@ org.springframework.boot spring-boot-starter-validation - - com.webforj - webforj-spring-devtools - true - - - org.springframework.boot - spring-boot-devtools - true - org.springframework.boot spring-boot-starter-test @@ -83,6 +73,11 @@ webforj-maven-plugin ${webforj.version} true + + + + + org.springframework.boot @@ -111,23 +106,6 @@ prod - - - - org.springframework.boot - spring-boot-maven-plugin - - true - - - com.webforj - webforj-spring-devtools - - - - - - dev diff --git a/3-routing-and-composites/src/main/resources/application.properties b/3-routing-and-composites/src/main/resources/application.properties index 5f29dbf..18be8f3 100644 --- a/3-routing-and-composites/src/main/resources/application.properties +++ b/3-routing-and-composites/src/main/resources/application.properties @@ -6,11 +6,11 @@ server.shutdown=immediate server.port=8080 # webforJ DevTools configuration -spring.devtools.livereload.enabled=false webforj.devtools.livereload.enabled=true webforj.devtools.livereload.static-resources-enabled=true webforj.devtools.browser.open=true webforj.debug=true +webforj.devtools.craftforj.enabled=true webforj.entry = com.webforj.tutorial.Application # H2 Database configuration diff --git a/3-routing-and-composites/src/main/resources/banner.txt b/3-routing-and-composites/src/main/resources/banner.txt index db3c9dd..2e28361 100644 --- a/3-routing-and-composites/src/main/resources/banner.txt +++ b/3-routing-and-composites/src/main/resources/banner.txt @@ -4,4 +4,4 @@ \ V V / __/ | |_) | _| (_) | | _| | \_/\_/ \___| |_.__/|_| \___/|_| |___| - :: webforJ (26.01) :: + :: webforJ (26.02) :: diff --git a/4-observers-and-route-parameters/README.md b/4-observers-and-route-parameters/README.md index 1ff1ef8..6b97b67 100644 --- a/4-observers-and-route-parameters/README.md +++ b/4-observers-and-route-parameters/README.md @@ -12,7 +12,7 @@ For a complete step-by-step guide to building this app, see the **[webforJ Tutor To run the app, ensure the following tools are installed: - Java 21 or higher -- BBj 26.01 when running with local BBjServices +- BBj 26.02 when running with local BBjServices - Maven - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser diff --git a/4-observers-and-route-parameters/pom.xml b/4-observers-and-route-parameters/pom.xml index f9b5fac..cb01674 100644 --- a/4-observers-and-route-parameters/pom.xml +++ b/4-observers-and-route-parameters/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.7 + 4.0.8 com.webforj.tutorial @@ -14,7 +14,7 @@ CustomerApplication 21 - 26.01 + 26.02 21 21 UTF-8 @@ -43,16 +43,6 @@ org.springframework.boot spring-boot-starter-validation - - com.webforj - webforj-spring-devtools - true - - - org.springframework.boot - spring-boot-devtools - true - org.springframework.boot spring-boot-starter-test @@ -83,6 +73,11 @@ webforj-maven-plugin ${webforj.version} true + + + + + org.springframework.boot @@ -111,23 +106,6 @@ prod - - - - org.springframework.boot - spring-boot-maven-plugin - - true - - - com.webforj - webforj-spring-devtools - - - - - - dev diff --git a/4-observers-and-route-parameters/src/main/resources/application.properties b/4-observers-and-route-parameters/src/main/resources/application.properties index 5f29dbf..18be8f3 100644 --- a/4-observers-and-route-parameters/src/main/resources/application.properties +++ b/4-observers-and-route-parameters/src/main/resources/application.properties @@ -6,11 +6,11 @@ server.shutdown=immediate server.port=8080 # webforJ DevTools configuration -spring.devtools.livereload.enabled=false webforj.devtools.livereload.enabled=true webforj.devtools.livereload.static-resources-enabled=true webforj.devtools.browser.open=true webforj.debug=true +webforj.devtools.craftforj.enabled=true webforj.entry = com.webforj.tutorial.Application # H2 Database configuration diff --git a/4-observers-and-route-parameters/src/main/resources/banner.txt b/4-observers-and-route-parameters/src/main/resources/banner.txt index db3c9dd..2e28361 100644 --- a/4-observers-and-route-parameters/src/main/resources/banner.txt +++ b/4-observers-and-route-parameters/src/main/resources/banner.txt @@ -4,4 +4,4 @@ \ V V / __/ | |_) | _| (_) | | _| | \_/\_/ \___| |_.__/|_| \___/|_| |___| - :: webforJ (26.01) :: + :: webforJ (26.02) :: diff --git a/5-validating-and-binding-data/README.md b/5-validating-and-binding-data/README.md index 1ff1ef8..6b97b67 100644 --- a/5-validating-and-binding-data/README.md +++ b/5-validating-and-binding-data/README.md @@ -12,7 +12,7 @@ For a complete step-by-step guide to building this app, see the **[webforJ Tutor To run the app, ensure the following tools are installed: - Java 21 or higher -- BBj 26.01 when running with local BBjServices +- BBj 26.02 when running with local BBjServices - Maven - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser diff --git a/5-validating-and-binding-data/pom.xml b/5-validating-and-binding-data/pom.xml index f9b5fac..cb01674 100644 --- a/5-validating-and-binding-data/pom.xml +++ b/5-validating-and-binding-data/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.7 + 4.0.8 com.webforj.tutorial @@ -14,7 +14,7 @@ CustomerApplication 21 - 26.01 + 26.02 21 21 UTF-8 @@ -43,16 +43,6 @@ org.springframework.boot spring-boot-starter-validation - - com.webforj - webforj-spring-devtools - true - - - org.springframework.boot - spring-boot-devtools - true - org.springframework.boot spring-boot-starter-test @@ -83,6 +73,11 @@ webforj-maven-plugin ${webforj.version} true + + + + + org.springframework.boot @@ -111,23 +106,6 @@ prod - - - - org.springframework.boot - spring-boot-maven-plugin - - true - - - com.webforj - webforj-spring-devtools - - - - - - dev diff --git a/5-validating-and-binding-data/src/main/resources/application.properties b/5-validating-and-binding-data/src/main/resources/application.properties index 5f29dbf..18be8f3 100644 --- a/5-validating-and-binding-data/src/main/resources/application.properties +++ b/5-validating-and-binding-data/src/main/resources/application.properties @@ -6,11 +6,11 @@ server.shutdown=immediate server.port=8080 # webforJ DevTools configuration -spring.devtools.livereload.enabled=false webforj.devtools.livereload.enabled=true webforj.devtools.livereload.static-resources-enabled=true webforj.devtools.browser.open=true webforj.debug=true +webforj.devtools.craftforj.enabled=true webforj.entry = com.webforj.tutorial.Application # H2 Database configuration diff --git a/5-validating-and-binding-data/src/main/resources/banner.txt b/5-validating-and-binding-data/src/main/resources/banner.txt index db3c9dd..2e28361 100644 --- a/5-validating-and-binding-data/src/main/resources/banner.txt +++ b/5-validating-and-binding-data/src/main/resources/banner.txt @@ -4,4 +4,4 @@ \ V V / __/ | |_) | _| (_) | | _| | \_/\_/ \___| |_.__/|_| \___/|_| |___| - :: webforJ (26.01) :: + :: webforJ (26.02) :: diff --git a/6-integrating-an-app-layout/README.md b/6-integrating-an-app-layout/README.md index 1ff1ef8..6b97b67 100644 --- a/6-integrating-an-app-layout/README.md +++ b/6-integrating-an-app-layout/README.md @@ -12,7 +12,7 @@ For a complete step-by-step guide to building this app, see the **[webforJ Tutor To run the app, ensure the following tools are installed: - Java 21 or higher -- BBj 26.01 when running with local BBjServices +- BBj 26.02 when running with local BBjServices - Maven - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser diff --git a/6-integrating-an-app-layout/pom.xml b/6-integrating-an-app-layout/pom.xml index f9b5fac..cb01674 100644 --- a/6-integrating-an-app-layout/pom.xml +++ b/6-integrating-an-app-layout/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.7 + 4.0.8 com.webforj.tutorial @@ -14,7 +14,7 @@ CustomerApplication 21 - 26.01 + 26.02 21 21 UTF-8 @@ -43,16 +43,6 @@ org.springframework.boot spring-boot-starter-validation - - com.webforj - webforj-spring-devtools - true - - - org.springframework.boot - spring-boot-devtools - true - org.springframework.boot spring-boot-starter-test @@ -83,6 +73,11 @@ webforj-maven-plugin ${webforj.version} true + + + + + org.springframework.boot @@ -111,23 +106,6 @@ prod - - - - org.springframework.boot - spring-boot-maven-plugin - - true - - - com.webforj - webforj-spring-devtools - - - - - - dev diff --git a/6-integrating-an-app-layout/src/main/resources/application.properties b/6-integrating-an-app-layout/src/main/resources/application.properties index 5f29dbf..18be8f3 100644 --- a/6-integrating-an-app-layout/src/main/resources/application.properties +++ b/6-integrating-an-app-layout/src/main/resources/application.properties @@ -6,11 +6,11 @@ server.shutdown=immediate server.port=8080 # webforJ DevTools configuration -spring.devtools.livereload.enabled=false webforj.devtools.livereload.enabled=true webforj.devtools.livereload.static-resources-enabled=true webforj.devtools.browser.open=true webforj.debug=true +webforj.devtools.craftforj.enabled=true webforj.entry = com.webforj.tutorial.Application # H2 Database configuration diff --git a/6-integrating-an-app-layout/src/main/resources/banner.txt b/6-integrating-an-app-layout/src/main/resources/banner.txt index db3c9dd..2e28361 100644 --- a/6-integrating-an-app-layout/src/main/resources/banner.txt +++ b/6-integrating-an-app-layout/src/main/resources/banner.txt @@ -4,4 +4,4 @@ \ V V / __/ | |_) | _| (_) | | _| | \_/\_/ \___| |_.__/|_| \___/|_| |___| - :: webforJ (26.01) :: + :: webforJ (26.02) :: diff --git a/README.md b/README.md index 1ff1ef8..6b97b67 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ For a complete step-by-step guide to building this app, see the **[webforJ Tutor To run the app, ensure the following tools are installed: - Java 21 or higher -- BBj 26.01 when running with local BBjServices +- BBj 26.02 when running with local BBjServices - Maven - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser From 2a7b4be15b1426d7faa49df4d5ea6d8a2b5c6d21 Mon Sep 17 00:00:00 2001 From: Eric Handtke Date: Wed, 2 Sep 2026 15:46:55 +0200 Subject: [PATCH 2/9] added end to end and screenshot testing --- .gitignore | 3 + 1-creating-a-basic-app/pom.xml | 20 -- 2-working-with-data/pom.xml | 20 -- 3-routing-and-composites/pom.xml | 20 -- 4-observers-and-route-parameters/pom.xml | 20 -- 5-validating-and-binding-data/pom.xml | 20 -- 6-integrating-an-app-layout/pom.xml | 20 -- README.md | 33 +++ e2e/scripts/run-steps.mjs | 238 ++++++++++++++++++ e2e/tests/step-1.spec.ts | 19 ++ ...asic-application-dialog-chromium-win32.png | Bin 0 -> 15382 bytes e2e/tests/step-2.spec.ts | 29 +++ .../customer-table-chromium-win32.png | Bin 0 -> 22237 bytes e2e/tests/step-3.spec.ts | 27 ++ ...completed-customer-form-chromium-win32.png | Bin 0 -> 19411 bytes e2e/tests/step-4.spec.ts | 34 +++ .../edit-customer-route-chromium-win32.png | Bin 0 -> 17824 bytes e2e/tests/step-5.spec.ts | 34 +++ .../valid-customer-form-chromium-win32.png | Bin 0 -> 19691 bytes e2e/tests/step-6.spec.ts | 39 +++ .../dashboard-layout-chromium-win32.png | Bin 0 -> 33686 bytes .../dashboard-mobile-chromium-win32.png | Bin 0 -> 18729 bytes e2e/tests/support/app.ts | 22 ++ e2e/tests/support/fixtures.ts | 30 +++ package-lock.json | 78 ++++++ package.json | 15 ++ playwright.config.ts | 48 ++++ 27 files changed, 649 insertions(+), 120 deletions(-) create mode 100644 e2e/scripts/run-steps.mjs create mode 100644 e2e/tests/step-1.spec.ts create mode 100644 e2e/tests/step-1.spec.ts-snapshots/basic-application-dialog-chromium-win32.png create mode 100644 e2e/tests/step-2.spec.ts create mode 100644 e2e/tests/step-2.spec.ts-snapshots/customer-table-chromium-win32.png create mode 100644 e2e/tests/step-3.spec.ts create mode 100644 e2e/tests/step-3.spec.ts-snapshots/completed-customer-form-chromium-win32.png create mode 100644 e2e/tests/step-4.spec.ts create mode 100644 e2e/tests/step-4.spec.ts-snapshots/edit-customer-route-chromium-win32.png create mode 100644 e2e/tests/step-5.spec.ts create mode 100644 e2e/tests/step-5.spec.ts-snapshots/valid-customer-form-chromium-win32.png create mode 100644 e2e/tests/step-6.spec.ts create mode 100644 e2e/tests/step-6.spec.ts-snapshots/dashboard-layout-chromium-win32.png create mode 100644 e2e/tests/step-6.spec.ts-snapshots/dashboard-mobile-chromium-win32.png create mode 100644 e2e/tests/support/app.ts create mode 100644 e2e/tests/support/fixtures.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index 874890f..f558b30 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ hs_err_pid* replay_pid* **/target +node_modules/ +playwright-report/ +test-results/ /.idea *.iml /.project diff --git a/1-creating-a-basic-app/pom.xml b/1-creating-a-basic-app/pom.xml index 8c2a243..44abbbf 100644 --- a/1-creating-a-basic-app/pom.xml +++ b/1-creating-a-basic-app/pom.xml @@ -50,12 +50,6 @@ - - com.microsoft.playwright - playwright - 1.49.0 - test - compile webforj:watch spring-boot:run @@ -78,20 +72,6 @@ true - - maven-failsafe-plugin - - - - integration-test - verify - - - - - false - - diff --git a/2-working-with-data/pom.xml b/2-working-with-data/pom.xml index cb01674..59e7357 100644 --- a/2-working-with-data/pom.xml +++ b/2-working-with-data/pom.xml @@ -58,12 +58,6 @@ com.h2database h2 - - com.microsoft.playwright - playwright - 1.49.0 - test - compile webforj:watch spring-boot:run @@ -86,20 +80,6 @@ true - - maven-failsafe-plugin - - - - integration-test - verify - - - - - false - - diff --git a/3-routing-and-composites/pom.xml b/3-routing-and-composites/pom.xml index cb01674..59e7357 100644 --- a/3-routing-and-composites/pom.xml +++ b/3-routing-and-composites/pom.xml @@ -58,12 +58,6 @@ com.h2database h2 - - com.microsoft.playwright - playwright - 1.49.0 - test - compile webforj:watch spring-boot:run @@ -86,20 +80,6 @@ true - - maven-failsafe-plugin - - - - integration-test - verify - - - - - false - - diff --git a/4-observers-and-route-parameters/pom.xml b/4-observers-and-route-parameters/pom.xml index cb01674..59e7357 100644 --- a/4-observers-and-route-parameters/pom.xml +++ b/4-observers-and-route-parameters/pom.xml @@ -58,12 +58,6 @@ com.h2database h2 - - com.microsoft.playwright - playwright - 1.49.0 - test - compile webforj:watch spring-boot:run @@ -86,20 +80,6 @@ true - - maven-failsafe-plugin - - - - integration-test - verify - - - - - false - - diff --git a/5-validating-and-binding-data/pom.xml b/5-validating-and-binding-data/pom.xml index cb01674..59e7357 100644 --- a/5-validating-and-binding-data/pom.xml +++ b/5-validating-and-binding-data/pom.xml @@ -58,12 +58,6 @@ com.h2database h2 - - com.microsoft.playwright - playwright - 1.49.0 - test - compile webforj:watch spring-boot:run @@ -86,20 +80,6 @@ true - - maven-failsafe-plugin - - - - integration-test - verify - - - - - false - - diff --git a/6-integrating-an-app-layout/pom.xml b/6-integrating-an-app-layout/pom.xml index cb01674..59e7357 100644 --- a/6-integrating-an-app-layout/pom.xml +++ b/6-integrating-an-app-layout/pom.xml @@ -58,12 +58,6 @@ com.h2database h2 - - com.microsoft.playwright - playwright - 1.49.0 - test - compile webforj:watch spring-boot:run @@ -86,20 +80,6 @@ true - - maven-failsafe-plugin - - - - integration-test - verify - - - - - false - - diff --git a/README.md b/README.md index 6b97b67..f3e5dba 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ To run the app, ensure the following tools are installed: - Java 21 or higher - BBj 26.02 when running with local BBjServices - Maven +- Node.js 20 or higher for end-to-end tests - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser - Git (recommended) @@ -49,6 +50,38 @@ webforj-tutorial ``` 3. Open your browser and go to [http://localhost:8080](http://localhost:8080). +## End-to-End and Screenshot Tests + +The root Playwright suite builds and starts each tutorial step separately, runs it against a fresh in-memory database, and stops it after testing. It uses port 8080 when available, falls back to 8090, and otherwise selects a free local port. + +Install the test dependencies and Chromium once: + +```sh +npm ci +npm run e2e:install +``` + +Run all six tutorial steps: + +```sh +npm run e2e +``` + +Run or debug an individual step: + +```sh +npm run e2e -- 3 +npm run e2e:headed -- 3 +``` + +Update committed screenshot baselines after an intentional visual change: + +```sh +npm run e2e:update -- 3 +``` + +Use `--skip-build` while iterating when the packaged application is already current. HTML reports are written under `playwright-report/step-N`, and failure screenshots, videos, traces, and application logs are written under `test-results/step-N`. + ## Project Highlights - **Spring Boot integration:** Autowire Spring beans directly into webforJ views and components. diff --git a/e2e/scripts/run-steps.mjs b/e2e/scripts/run-steps.mjs new file mode 100644 index 0000000..150c573 --- /dev/null +++ b/e2e/scripts/run-steps.mjs @@ -0,0 +1,238 @@ +import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { basename, dirname, join, resolve } from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const stepDirectories = new Map([ + ['1', '1-creating-a-basic-app'], + ['2', '2-working-with-data'], + ['3', '3-routing-and-composites'], + ['4', '4-observers-and-route-parameters'], + ['5', '5-validating-and-binding-data'], + ['6', '6-integrating-an-app-layout'] +]); + +const rawArguments = process.argv.slice(2); +const requestedSteps = []; +const playwrightArguments = []; +let skipBuild = false; + +for (let index = 0; index < rawArguments.length; index += 1) { + const argument = rawArguments[index]; + + if (argument === '--skip-build') { + skipBuild = true; + } else if (argument === '--step' && rawArguments[index + 1]) { + requestedSteps.push(rawArguments[index + 1]); + index += 1; + } else if (argument.startsWith('--step=')) { + requestedSteps.push(argument.substring('--step='.length)); + } else if (/^[1-6]$/.test(argument)) { + requestedSteps.push(argument); + } else { + playwrightArguments.push(argument); + } +} + +const steps = requestedSteps.length > 0 + ? [...new Set(requestedSteps)] + : [...stepDirectories.keys()]; + +for (const step of steps) { + if (!stepDirectories.has(step)) { + throw new Error(`Unknown tutorial step: ${step}. Expected a number from 1 to 6.`); + } +} + +function run(command, args, options = {}) { + return new Promise((resolvePromise, rejectPromise) => { + const childCommand = process.platform === 'win32' + ? (process.env.ComSpec ?? 'cmd.exe') + : command; + const childArguments = process.platform === 'win32' + ? ['/d', '/s', '/c', `${command}.cmd`, ...args] + : args; + const child = spawn(childCommand, childArguments, { + cwd: repositoryRoot, + stdio: 'inherit', + ...options + }); + + child.on('error', rejectPromise); + child.on('exit', (code, signal) => { + if (code === 0) { + resolvePromise(); + } else { + rejectPromise(new Error( + `${basename(command)} exited with ${code ?? signal ?? 'an unknown status'}`)); + } + }); + }); +} + +function canListen(port) { + return new Promise(resolvePromise => { + const server = createServer(); + server.unref(); + server.once('error', () => resolvePromise(false)); + server.listen({ host: '127.0.0.1', port }, () => { + server.close(() => resolvePromise(true)); + }); + }); +} + +async function findFreePort() { + for (const preferredPort of [8080, 8090]) { + if (await canListen(preferredPort)) { + return preferredPort; + } + } + + return new Promise((resolvePromise, rejectPromise) => { + const server = createServer(); + server.unref(); + server.once('error', rejectPromise); + server.listen({ host: '127.0.0.1', port: 0 }, () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + server.close(() => resolvePromise(port)); + }); + }); +} + +function findApplicationJar(stepDirectory) { + const targetDirectory = join(repositoryRoot, stepDirectory, 'target'); + const jars = readdirSync(targetDirectory) + .filter(file => file.endsWith('.jar') && !file.endsWith('.jar.original')); + + if (jars.length !== 1) { + throw new Error(`Expected exactly one application JAR in ${targetDirectory}, found ${jars.length}.`); + } + + return join(targetDirectory, jars[0]); +} + +function tail(file, lines = 80) { + if (!existsSync(file)) { + return '(server log was not created)'; + } + + return readFileSync(file, 'utf8').split(/\r?\n/).slice(-lines).join('\n'); +} + +async function waitForApplication(url, application, logFile) { + const deadline = Date.now() + 180_000; + + while (Date.now() < deadline) { + if (application.exitCode !== null) { + throw new Error(`Application exited before becoming ready.\n${tail(logFile)}`); + } + + try { + const response = await fetch(url, { redirect: 'manual' }); + if (response.status < 500) { + return; + } + } catch { + // The server is still starting. + } + + await new Promise(resolvePromise => setTimeout(resolvePromise, 500)); + } + + throw new Error(`Application did not become ready within 180 seconds.\n${tail(logFile)}`); +} + +async function stopApplication(application) { + if (application.exitCode !== null) { + return; + } + + application.kill('SIGTERM'); + await Promise.race([ + new Promise(resolvePromise => application.once('exit', resolvePromise)), + new Promise(resolvePromise => setTimeout(resolvePromise, 5_000)) + ]); + + if (application.exitCode === null) { + application.kill('SIGKILL'); + } +} + +async function runStep(step) { + const stepDirectory = stepDirectories.get(step); + const pom = join(stepDirectory, 'pom.xml'); + + console.log(`\n=== Tutorial step ${step}: ${stepDirectory} ===`); + + if (!skipBuild) { + await run('mvn', ['-ntp', '-f', pom, '-DskipTests', 'package']); + } + + const jar = findApplicationJar(stepDirectory); + const port = await findFreePort(); + const baseURL = `http://127.0.0.1:${port}`; + const artifactDirectory = join(repositoryRoot, 'test-results', `step-${step}`); + const logFile = join(artifactDirectory, 'server.log'); + mkdirSync(artifactDirectory, { recursive: true }); + const logDescriptor = openSync(logFile, 'w'); + + console.log(`Starting ${stepDirectory} on ${baseURL}`); + + const application = spawn('java', [ + '-jar', jar, + `--server.port=${port}`, + '--server.address=127.0.0.1', + '--spring.jpa.hibernate.ddl-auto=create-drop', + '--webforj.devtools.browser.open=false', + '--webforj.devtools.livereload.enabled=false', + '--webforj.devtools.livereload.static-resources-enabled=false', + '--webforj.devtools.craftforj.enabled=false', + '--webforj.debug=false' + ], { + cwd: join(repositoryRoot, stepDirectory), + stdio: ['ignore', logDescriptor, logDescriptor] + }); + + try { + await waitForApplication(baseURL, application, logFile); + await run('npx', [ + '--no-install', + 'playwright', + 'test', + ...playwrightArguments + ], { + env: { + ...process.env, + E2E_STEP: step, + E2E_BASE_URL: baseURL + } + }); + } catch (error) { + console.error(`\nServer log: ${logFile}`); + throw error; + } finally { + await stopApplication(application); + closeSync(logDescriptor); + } +} + +const failures = []; + +for (const step of steps) { + try { + await runStep(step); + } catch (error) { + failures.push({ step, error }); + console.error(`Step ${step} failed: ${error.message}`); + } +} + +if (failures.length > 0) { + console.error(`\n${failures.length} tutorial step(s) failed: ${failures.map(({ step }) => step).join(', ')}`); + process.exitCode = 1; +} else { + console.log(`\nAll requested tutorial steps passed: ${steps.join(', ')}`); +} diff --git a/e2e/tests/step-1.spec.ts b/e2e/tests/step-1.spec.ts new file mode 100644 index 0000000..ffc2653 --- /dev/null +++ b/e2e/tests/step-1.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from './support/fixtures'; +import { openApplication } from './support/app'; + +test('renders the basic application and opens the information dialog', async ({ page, browserErrors }) => { + await openApplication(page); + + await expect(page.getByText('Tutorial App!', { exact: true })).toBeVisible(); + + const infoButton = page.getByRole('button', { name: 'Info', exact: true }); + await expect(infoButton).toBeVisible(); + await infoButton.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect(page.getByText('This is a tutorial!', { exact: true })).toBeVisible(); + await expect(page).toHaveScreenshot('basic-application-dialog.png'); + + expect(browserErrors).toEqual([]); +}); diff --git a/e2e/tests/step-1.spec.ts-snapshots/basic-application-dialog-chromium-win32.png b/e2e/tests/step-1.spec.ts-snapshots/basic-application-dialog-chromium-win32.png new file mode 100644 index 0000000000000000000000000000000000000000..4f7189cd6bdccc47bca8d0f2d0aa8402f862b780 GIT binary patch literal 15382 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 DmYtIv literal 0 HcmV?d00001 diff --git a/e2e/tests/step-2.spec.ts b/e2e/tests/step-2.spec.ts new file mode 100644 index 0000000..cb13483 --- /dev/null +++ b/e2e/tests/step-2.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from './support/fixtures'; +import { expectCustomerTable, openApplication } from './support/app'; + +test('renders seeded customer data and keeps the information dialog functional', async ({ page, browserErrors }) => { + await openApplication(page); + + await expect(page.getByText('Tutorial App!', { exact: true })).toBeVisible(); + const table = await expectCustomerTable(page); + await expect(table).toContainText('John'); + await expect(table).toContainText('Innovatech'); + + await page.getByRole('button', { name: 'Info', exact: true }).click(); + await expect(page.getByText('This is a tutorial!', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'OK' }).click(); + + await expect(page.getByRole('dialog')).toBeHidden(); + await expect(page).toHaveScreenshot('customer-table.png'); + + const firstNameHeader = page.getByRole('columnheader', { name: /^First Name/ }); + const rowTop = async (name: string) => + (await page.getByRole('cell', { name, exact: true }).boundingBox())?.y ?? Number.MAX_SAFE_INTEGER; + + await firstNameHeader.click(); + await expect.poll(async () => await rowTop('Emma') < await rowTop('John')).toBe(true); + await firstNameHeader.click(); + await expect.poll(async () => await rowTop('John') < await rowTop('Emma')).toBe(true); + + expect(browserErrors).toEqual([]); +}); diff --git a/e2e/tests/step-2.spec.ts-snapshots/customer-table-chromium-win32.png b/e2e/tests/step-2.spec.ts-snapshots/customer-table-chromium-win32.png new file mode 100644 index 0000000000000000000000000000000000000000..04aed2a4eacfc0a2f4596b16385583c5f950acf3 GIT binary patch literal 22237 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 F{{S!0n}YxV literal 0 HcmV?d00001 diff --git a/e2e/tests/step-3.spec.ts b/e2e/tests/step-3.spec.ts new file mode 100644 index 0000000..469d160 --- /dev/null +++ b/e2e/tests/step-3.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from './support/fixtures'; +import { customerTable, expectCustomerTable, openApplication } from './support/app'; + +test('navigates to the customer form, creates a customer, and returns to the table', async ({ page, browserErrors }) => { + await openApplication(page); + await expectCustomerTable(page); + + await page.getByRole('button', { name: 'Add Customer' }).click(); + await expect(page).toHaveURL(/\/customer$/); + await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible(); + + await page.getByLabel('First Name').fill('Ada'); + await page.getByLabel('Last Name').fill('Lovelace'); + await page.getByLabel('Company').fill('Analytical Engines'); + await expect(page).toHaveScreenshot('completed-customer-form.png'); + + await page.getByRole('button', { name: 'Submit' }).click(); + await expect(page).toHaveURL(/\/$/); + await expect(customerTable(page)).toContainText('Ada'); + await expect(customerTable(page)).toContainText('Analytical Engines'); + + await page.getByRole('button', { name: 'Add Customer' }).click(); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page).toHaveURL(/\/$/); + expect(browserErrors).toEqual([]); +}); diff --git a/e2e/tests/step-3.spec.ts-snapshots/completed-customer-form-chromium-win32.png b/e2e/tests/step-3.spec.ts-snapshots/completed-customer-form-chromium-win32.png new file mode 100644 index 0000000000000000000000000000000000000000..07eb8cb417c1846ab43ba603aaa8c227674b2f8d GIT binary patch literal 19411 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 literal 0 HcmV?d00001 diff --git a/e2e/tests/step-4.spec.ts b/e2e/tests/step-4.spec.ts new file mode 100644 index 0000000..69e038c --- /dev/null +++ b/e2e/tests/step-4.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from './support/fixtures'; +import { customerTable, expectCustomerTable, openApplication } from './support/app'; + +test('shows the routed customer form consistently', async ({ page }) => { + await openApplication(page, '/customer/1'); + await expect(page.getByLabel('First Name')).toHaveValue('Alice'); + await expect(page.getByLabel('Last Name')).toHaveValue('Smith'); + await expect(page.getByLabel('Company')).toHaveValue('TechCorp'); + await expect(page).toHaveScreenshot('edit-customer-route.png'); +}); + +test('loads a customer through its route parameter and saves edits', async ({ page, browserErrors }) => { + await openApplication(page); + await expectCustomerTable(page); + + await page.getByText('Alice', { exact: true }).click(); + await expect(page).toHaveURL(/\/customer\/1$/); + await expect(page.getByLabel('First Name')).toHaveValue('Alice'); + await expect(page.getByLabel('Last Name')).toHaveValue('Smith'); + + const company = page.getByLabel('Company'); + await company.click(); + await company.press('ControlOrMeta+A'); + await company.pressSequentially('Updated TechCorp', { delay: 25 }); + await expect(company).toHaveValue('Updated TechCorp'); + await page.getByRole('button', { name: 'Submit' }).click(); + await expect(page).toHaveURL(/\/$/); + await expect(page.getByRole('cell', { name: 'Updated TechCorp', exact: true })).toBeVisible(); + + await openApplication(page, '/customer/999999'); + await expect(page).toHaveURL(/\/$/); + await expect(customerTable(page)).toBeVisible(); + expect(browserErrors).toEqual([]); +}); diff --git a/e2e/tests/step-4.spec.ts-snapshots/edit-customer-route-chromium-win32.png b/e2e/tests/step-4.spec.ts-snapshots/edit-customer-route-chromium-win32.png new file mode 100644 index 0000000000000000000000000000000000000000..0564a50f96837cce55acc8a81e9c9811b5342a30 GIT binary patch literal 17824 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 Vm?3%LMsN?Ht*QTek@~~u{|l { + await openApplication(page); + await expectCustomerTable(page); + + await page.getByRole('button', { name: 'Add Customer' }).click(); + const submit = page.getByRole('button', { name: 'Submit' }); + await expect(submit).toBeEnabled(); + + await page.getByLabel('First Name').fill('Grace'); + await submit.click(); + await expect(page).toHaveURL(/\/customer$/); + await expect(page.getByText('Customer last name is required', { exact: true })).toBeVisible(); + await page.getByLabel('Last Name').fill('Hopper'); + await page.getByLabel('Company').fill('Compiler Systems'); + await expect(page.getByText('Customer last name is required', { exact: true })).toBeHidden(); + await expect(submit).toBeEnabled(); + await expect(page).toHaveScreenshot('valid-customer-form.png'); + + await submit.click(); + await expect(page).toHaveURL(/\/$/); + await expect(customerTable(page)).toContainText('Grace'); + await expect(customerTable(page)).toContainText('Compiler Systems'); + expect(browserErrors).toEqual([]); +}); + +test('loads an existing customer into the binding context', async ({ page }) => { + await openApplication(page, '/customer/1'); + await expect(page.getByLabel('First Name')).toHaveValue('Alice'); + await expect(page.getByLabel('Last Name')).toHaveValue('Smith'); + await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled(); +}); diff --git a/e2e/tests/step-5.spec.ts-snapshots/valid-customer-form-chromium-win32.png b/e2e/tests/step-5.spec.ts-snapshots/valid-customer-form-chromium-win32.png new file mode 100644 index 0000000000000000000000000000000000000000..162f61b535faf1c2f60d3400d1b2b2f48276dcbb GIT binary patch literal 19691 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 { + await openApplication(page); + await expectCustomerTable(page); + await expect(page.getByRole('heading', { name: 'Customer Table', level: 1 })).toBeVisible(); + await expect(page).toHaveScreenshot('dashboard-layout.png'); + + await page.getByText('About', { exact: true }).click(); + await expect(page).toHaveURL(/\/about$/); + await expect(page.getByRole('heading', { name: 'About', level: 1 })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Customer Manager', level: 2 }).last()).toBeVisible(); + + await page.getByText('Dashboard', { exact: true }).click(); + await expect(page).toHaveURL(/\/$/); + await expect(customerTable(page)).toBeVisible(); + + await page.getByRole('button', { name: 'Add Customer' }).click(); + await expect(page).toHaveURL(/\/customer$/); + await expect(page.getByRole('heading', { name: 'Customer Form', level: 1 })).toBeVisible(); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page).toHaveURL(/\/$/); + expect(browserErrors).toEqual([]); +}); + +test('keeps the application layout usable at a narrow viewport', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openApplication(page); + + await expect(page.getByRole('heading', { name: 'Customer Table', level: 1 })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Add Customer' })).toBeVisible(); + await expect(page).toHaveScreenshot('dashboard-mobile.png'); + + const dashboardLink = page.getByRole('link', { name: /Dashboard/ }); + await expect(dashboardLink).not.toBeInViewport(); + await page.getByRole('button', { name: /menu/i }).click(); + await expect(dashboardLink).toBeInViewport(); +}); diff --git a/e2e/tests/step-6.spec.ts-snapshots/dashboard-layout-chromium-win32.png b/e2e/tests/step-6.spec.ts-snapshots/dashboard-layout-chromium-win32.png new file mode 100644 index 0000000000000000000000000000000000000000..7fd9f9506c1d23556da3a2fa10a700d745a60f16 GIT binary patch literal 33686 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 literal 0 HcmV?d00001 diff --git a/e2e/tests/step-6.spec.ts-snapshots/dashboard-mobile-chromium-win32.png b/e2e/tests/step-6.spec.ts-snapshots/dashboard-mobile-chromium-win32.png new file mode 100644 index 0000000000000000000000000000000000000000..87fe5d32ddfb7336634cfca9f5533defcb5506a4 GIT binary patch literal 18729 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 + browserErrors: [async ({ page }, use) => { + const browserErrors: string[] = []; + + page.on('pageerror', error => { + browserErrors.push(`pageerror: ${error.message}`); + }); + page.on('console', message => { + if (message.type() === 'error') { + browserErrors.push(`console: ${message.text()}`); + } + }); + page.on('requestfailed', request => { + browserErrors.push( + `requestfailed: ${request.method()} ${request.url()} (${request.failure()?.errorText ?? 'unknown error'})`); + }); + + await use(browserErrors); + + expect.soft(browserErrors, 'The browser should not report runtime or network errors').toEqual([]); + }, { auto: true }] +}); + +export { expect } from '@playwright/test'; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3edc818 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "webforj-tutorial-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "webforj-tutorial-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "1.62.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..474a324 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "webforj-tutorial-e2e", + "version": "1.0.0", + "private": true, + "description": "End-to-end and visual regression tests for the webforJ tutorial steps", + "scripts": { + "e2e": "node e2e/scripts/run-steps.mjs", + "e2e:headed": "node e2e/scripts/run-steps.mjs --headed", + "e2e:update": "node e2e/scripts/run-steps.mjs --update-snapshots", + "e2e:install": "playwright install chromium" + }, + "devDependencies": { + "@playwright/test": "1.62.1" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..a458b1f --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,48 @@ +import { defineConfig, devices } from '@playwright/test'; + +const step = process.env.E2E_STEP ?? '1'; +const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:8080'; + +export default defineConfig({ + testDir: './e2e/tests', + testMatch: `step-${step}.spec.ts`, + outputDir: `test-results/step-${step}/playwright`, + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 1 : 0, + forbidOnly: Boolean(process.env.CI), + timeout: 45_000, + expect: { + timeout: 10_000, + toHaveScreenshot: { + animations: 'disabled', + caret: 'hide', + maxDiffPixels: 200, + scale: 'css' + } + }, + reporter: [ + ['list'], + ['html', { outputFolder: `playwright-report/step-${step}`, open: 'never' }] + ], + use: { + baseURL, + colorScheme: 'light', + locale: 'en-US', + timezoneId: 'UTC', + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 1, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + video: 'retain-on-failure' + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 900 } + } + } + ] +}); From 3a0c77780851d94b120e4ed9b4eacc4f0eb2155e Mon Sep 17 00:00:00 2001 From: Eric Handtke Date: Thu, 3 Sep 2026 11:14:47 +0200 Subject: [PATCH 3/9] Changed from javascript to java, replaced screenshots --- .dockerignore | 6 + Dockerfile | 12 + README.md | 27 +- e2e/scripts/run-steps.mjs | 238 ------------- .../com/webforj/tutorial/e2e/BaseTest.java | 142 ++++++++ .../com/webforj/tutorial/e2e/Step1IT.java | 30 ++ .../com/webforj/tutorial/e2e/Step2IT.java | 59 ++++ .../com/webforj/tutorial/e2e/Step3IT.java | 46 +++ .../com/webforj/tutorial/e2e/Step4IT.java | 57 ++++ .../com/webforj/tutorial/e2e/Step5IT.java | 52 +++ .../com/webforj/tutorial/e2e/Step6IT.java | 85 +++++ .../com/webforj/tutorial/e2e/TutorialApp.java | 183 ++++++++++ .../tutorial/e2e/VisualAssertions.java | 125 +++++++ .../screenshots/basic-application-dialog.png | Bin 0 -> 17215 bytes .../screenshots/completed-customer-form.png | Bin 0 -> 21724 bytes .../resources/screenshots/customer-table.png | Bin 0 -> 34907 bytes .../screenshots/dashboard-layout.png | Bin 0 -> 49066 bytes .../screenshots/dashboard-mobile.png | Bin 0 -> 29744 bytes .../screenshots/edit-customer-route.png | Bin 0 -> 19932 bytes .../screenshots/valid-customer-form.png | Bin 0 -> 22249 bytes e2e/tests/step-1.spec.ts | 19 -- ...asic-application-dialog-chromium-win32.png | Bin 15382 -> 0 bytes e2e/tests/step-2.spec.ts | 29 -- .../customer-table-chromium-win32.png | Bin 22237 -> 0 bytes e2e/tests/step-3.spec.ts | 27 -- ...completed-customer-form-chromium-win32.png | Bin 19411 -> 0 bytes e2e/tests/step-4.spec.ts | 34 -- .../edit-customer-route-chromium-win32.png | Bin 17824 -> 0 bytes e2e/tests/step-5.spec.ts | 34 -- .../valid-customer-form-chromium-win32.png | Bin 19691 -> 0 bytes e2e/tests/step-6.spec.ts | 39 --- .../dashboard-layout-chromium-win32.png | Bin 33686 -> 0 bytes .../dashboard-mobile-chromium-win32.png | Bin 18729 -> 0 bytes e2e/tests/support/app.ts | 22 -- e2e/tests/support/fixtures.ts | 30 -- package-lock.json | 78 ----- package.json | 15 - playwright.config.ts | 48 --- pom.xml | 323 ++++++++++++++++++ 39 files changed, 1134 insertions(+), 626 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile delete mode 100644 e2e/scripts/run-steps.mjs create mode 100644 e2e/src/test/java/com/webforj/tutorial/e2e/BaseTest.java create mode 100644 e2e/src/test/java/com/webforj/tutorial/e2e/Step1IT.java create mode 100644 e2e/src/test/java/com/webforj/tutorial/e2e/Step2IT.java create mode 100644 e2e/src/test/java/com/webforj/tutorial/e2e/Step3IT.java create mode 100644 e2e/src/test/java/com/webforj/tutorial/e2e/Step4IT.java create mode 100644 e2e/src/test/java/com/webforj/tutorial/e2e/Step5IT.java create mode 100644 e2e/src/test/java/com/webforj/tutorial/e2e/Step6IT.java create mode 100644 e2e/src/test/java/com/webforj/tutorial/e2e/TutorialApp.java create mode 100644 e2e/src/test/java/com/webforj/tutorial/e2e/VisualAssertions.java create mode 100644 e2e/src/test/resources/screenshots/basic-application-dialog.png create mode 100644 e2e/src/test/resources/screenshots/completed-customer-form.png create mode 100644 e2e/src/test/resources/screenshots/customer-table.png create mode 100644 e2e/src/test/resources/screenshots/dashboard-layout.png create mode 100644 e2e/src/test/resources/screenshots/dashboard-mobile.png create mode 100644 e2e/src/test/resources/screenshots/edit-customer-route.png create mode 100644 e2e/src/test/resources/screenshots/valid-customer-form.png delete mode 100644 e2e/tests/step-1.spec.ts delete mode 100644 e2e/tests/step-1.spec.ts-snapshots/basic-application-dialog-chromium-win32.png delete mode 100644 e2e/tests/step-2.spec.ts delete mode 100644 e2e/tests/step-2.spec.ts-snapshots/customer-table-chromium-win32.png delete mode 100644 e2e/tests/step-3.spec.ts delete mode 100644 e2e/tests/step-3.spec.ts-snapshots/completed-customer-form-chromium-win32.png delete mode 100644 e2e/tests/step-4.spec.ts delete mode 100644 e2e/tests/step-4.spec.ts-snapshots/edit-customer-route-chromium-win32.png delete mode 100644 e2e/tests/step-5.spec.ts delete mode 100644 e2e/tests/step-5.spec.ts-snapshots/valid-customer-form-chromium-win32.png delete mode 100644 e2e/tests/step-6.spec.ts delete mode 100644 e2e/tests/step-6.spec.ts-snapshots/dashboard-layout-chromium-win32.png delete mode 100644 e2e/tests/step-6.spec.ts-snapshots/dashboard-mobile-chromium-win32.png delete mode 100644 e2e/tests/support/app.ts delete mode 100644 e2e/tests/support/fixtures.ts delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 playwright.config.ts create mode 100644 pom.xml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..93462ff --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.idea +**/target +node_modules +playwright-report +test-results diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e32a4eb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +# Keep this image tag exactly aligned with the Playwright Java dependency in pom.xml. +FROM mcr.microsoft.com/playwright/java:v1.50.0-noble + +WORKDIR /app +COPY . /app + +ENV CI=true +ENV E2E_IN_DOCKER=true +ENV MAVEN_CONFIG=/var/maven/.m2 +RUN mkdir -p /var/maven/.m2 + +CMD ["mvn", "-ntp", "-Ddocker.e2e.container=true", "verify"] diff --git a/README.md b/README.md index f3e5dba..a283bb2 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ To run the app, ensure the following tools are installed: - Java 21 or higher - BBj 26.02 when running with local BBjServices - Maven -- Node.js 20 or higher for end-to-end tests +- Docker Desktop (or another Docker engine) for end-to-end tests - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser - Git (recommended) @@ -52,35 +52,36 @@ webforj-tutorial ## End-to-End and Screenshot Tests -The root Playwright suite builds and starts each tutorial step separately, runs it against a fresh in-memory database, and stops it after testing. It uses port 8080 when available, falls back to 8090, and otherwise selects a free local port. +The root Maven project runs the Java Playwright suite exclusively in Docker. The container packages and starts each tutorial step separately, uses Chromium from the pinned official Playwright Java image, and stops the application after that step's tests. Applications prefer port 8080, fall back to 8090, and otherwise select a free container port. -Install the test dependencies and Chromium once: +Run all six tutorial steps, including screenshot comparisons: ```sh -npm ci -npm run e2e:install +mvn verify ``` -Run all six tutorial steps: +Run an individual step in Docker: ```sh -npm run e2e +mvn verify -Dit.test=Step3IT ``` -Run or debug an individual step: +After an intentional visual change, regenerate every committed baseline in the same Docker environment: ```sh -npm run e2e -- 3 -npm run e2e:headed -- 3 +mvn verify -DupdateScreenshots=true ``` -Update committed screenshot baselines after an intentional visual change: +You can also use the documentation-style explicit Docker goals. Build the image once, then run or update tests with the existing image: ```sh -npm run e2e:update -- 3 +mvn exec:exec@docker-e2e-build +mvn exec:exec@docker-e2e +mvn exec:exec@docker-e2e-single -Dtest=Step3IT +mvn exec:exec@docker-e2e-update ``` -Use `--skip-build` while iterating when the packaged application is already current. HTML reports are written under `playwright-report/step-N`, and failure screenshots, videos, traces, and application logs are written under `test-results/step-N`. +Screenshot baselines are stored in `e2e/src/test/resources/screenshots`. The comparator allows up to 500 anti-aliased pixels to differ in each 1440×900 capture; failure images are written to `target/visual-diffs`, Playwright traces to `target/playwright-traces`, and application logs to `target/e2e-artifacts`. The inner test profile also rejects execution unless `E2E_IN_DOCKER=true`, preventing accidental host-generated baselines. ## Project Highlights diff --git a/e2e/scripts/run-steps.mjs b/e2e/scripts/run-steps.mjs deleted file mode 100644 index 150c573..0000000 --- a/e2e/scripts/run-steps.mjs +++ /dev/null @@ -1,238 +0,0 @@ -import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync } from 'node:fs'; -import { createServer } from 'node:net'; -import { basename, dirname, join, resolve } from 'node:path'; -import { spawn } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); -const stepDirectories = new Map([ - ['1', '1-creating-a-basic-app'], - ['2', '2-working-with-data'], - ['3', '3-routing-and-composites'], - ['4', '4-observers-and-route-parameters'], - ['5', '5-validating-and-binding-data'], - ['6', '6-integrating-an-app-layout'] -]); - -const rawArguments = process.argv.slice(2); -const requestedSteps = []; -const playwrightArguments = []; -let skipBuild = false; - -for (let index = 0; index < rawArguments.length; index += 1) { - const argument = rawArguments[index]; - - if (argument === '--skip-build') { - skipBuild = true; - } else if (argument === '--step' && rawArguments[index + 1]) { - requestedSteps.push(rawArguments[index + 1]); - index += 1; - } else if (argument.startsWith('--step=')) { - requestedSteps.push(argument.substring('--step='.length)); - } else if (/^[1-6]$/.test(argument)) { - requestedSteps.push(argument); - } else { - playwrightArguments.push(argument); - } -} - -const steps = requestedSteps.length > 0 - ? [...new Set(requestedSteps)] - : [...stepDirectories.keys()]; - -for (const step of steps) { - if (!stepDirectories.has(step)) { - throw new Error(`Unknown tutorial step: ${step}. Expected a number from 1 to 6.`); - } -} - -function run(command, args, options = {}) { - return new Promise((resolvePromise, rejectPromise) => { - const childCommand = process.platform === 'win32' - ? (process.env.ComSpec ?? 'cmd.exe') - : command; - const childArguments = process.platform === 'win32' - ? ['/d', '/s', '/c', `${command}.cmd`, ...args] - : args; - const child = spawn(childCommand, childArguments, { - cwd: repositoryRoot, - stdio: 'inherit', - ...options - }); - - child.on('error', rejectPromise); - child.on('exit', (code, signal) => { - if (code === 0) { - resolvePromise(); - } else { - rejectPromise(new Error( - `${basename(command)} exited with ${code ?? signal ?? 'an unknown status'}`)); - } - }); - }); -} - -function canListen(port) { - return new Promise(resolvePromise => { - const server = createServer(); - server.unref(); - server.once('error', () => resolvePromise(false)); - server.listen({ host: '127.0.0.1', port }, () => { - server.close(() => resolvePromise(true)); - }); - }); -} - -async function findFreePort() { - for (const preferredPort of [8080, 8090]) { - if (await canListen(preferredPort)) { - return preferredPort; - } - } - - return new Promise((resolvePromise, rejectPromise) => { - const server = createServer(); - server.unref(); - server.once('error', rejectPromise); - server.listen({ host: '127.0.0.1', port: 0 }, () => { - const address = server.address(); - const port = typeof address === 'object' && address ? address.port : 0; - server.close(() => resolvePromise(port)); - }); - }); -} - -function findApplicationJar(stepDirectory) { - const targetDirectory = join(repositoryRoot, stepDirectory, 'target'); - const jars = readdirSync(targetDirectory) - .filter(file => file.endsWith('.jar') && !file.endsWith('.jar.original')); - - if (jars.length !== 1) { - throw new Error(`Expected exactly one application JAR in ${targetDirectory}, found ${jars.length}.`); - } - - return join(targetDirectory, jars[0]); -} - -function tail(file, lines = 80) { - if (!existsSync(file)) { - return '(server log was not created)'; - } - - return readFileSync(file, 'utf8').split(/\r?\n/).slice(-lines).join('\n'); -} - -async function waitForApplication(url, application, logFile) { - const deadline = Date.now() + 180_000; - - while (Date.now() < deadline) { - if (application.exitCode !== null) { - throw new Error(`Application exited before becoming ready.\n${tail(logFile)}`); - } - - try { - const response = await fetch(url, { redirect: 'manual' }); - if (response.status < 500) { - return; - } - } catch { - // The server is still starting. - } - - await new Promise(resolvePromise => setTimeout(resolvePromise, 500)); - } - - throw new Error(`Application did not become ready within 180 seconds.\n${tail(logFile)}`); -} - -async function stopApplication(application) { - if (application.exitCode !== null) { - return; - } - - application.kill('SIGTERM'); - await Promise.race([ - new Promise(resolvePromise => application.once('exit', resolvePromise)), - new Promise(resolvePromise => setTimeout(resolvePromise, 5_000)) - ]); - - if (application.exitCode === null) { - application.kill('SIGKILL'); - } -} - -async function runStep(step) { - const stepDirectory = stepDirectories.get(step); - const pom = join(stepDirectory, 'pom.xml'); - - console.log(`\n=== Tutorial step ${step}: ${stepDirectory} ===`); - - if (!skipBuild) { - await run('mvn', ['-ntp', '-f', pom, '-DskipTests', 'package']); - } - - const jar = findApplicationJar(stepDirectory); - const port = await findFreePort(); - const baseURL = `http://127.0.0.1:${port}`; - const artifactDirectory = join(repositoryRoot, 'test-results', `step-${step}`); - const logFile = join(artifactDirectory, 'server.log'); - mkdirSync(artifactDirectory, { recursive: true }); - const logDescriptor = openSync(logFile, 'w'); - - console.log(`Starting ${stepDirectory} on ${baseURL}`); - - const application = spawn('java', [ - '-jar', jar, - `--server.port=${port}`, - '--server.address=127.0.0.1', - '--spring.jpa.hibernate.ddl-auto=create-drop', - '--webforj.devtools.browser.open=false', - '--webforj.devtools.livereload.enabled=false', - '--webforj.devtools.livereload.static-resources-enabled=false', - '--webforj.devtools.craftforj.enabled=false', - '--webforj.debug=false' - ], { - cwd: join(repositoryRoot, stepDirectory), - stdio: ['ignore', logDescriptor, logDescriptor] - }); - - try { - await waitForApplication(baseURL, application, logFile); - await run('npx', [ - '--no-install', - 'playwright', - 'test', - ...playwrightArguments - ], { - env: { - ...process.env, - E2E_STEP: step, - E2E_BASE_URL: baseURL - } - }); - } catch (error) { - console.error(`\nServer log: ${logFile}`); - throw error; - } finally { - await stopApplication(application); - closeSync(logDescriptor); - } -} - -const failures = []; - -for (const step of steps) { - try { - await runStep(step); - } catch (error) { - failures.push({ step, error }); - console.error(`Step ${step} failed: ${error.message}`); - } -} - -if (failures.length > 0) { - console.error(`\n${failures.length} tutorial step(s) failed: ${failures.map(({ step }) => step).join(', ')}`); - process.exitCode = 1; -} else { - console.log(`\nAll requested tutorial steps passed: ${steps.join(', ')}`); -} diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/BaseTest.java b/e2e/src/test/java/com/webforj/tutorial/e2e/BaseTest.java new file mode 100644 index 0000000..1807a58 --- /dev/null +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/BaseTest.java @@ -0,0 +1,142 @@ +package com.webforj.tutorial.e2e; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.ConsoleMessage; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.Request; +import com.microsoft.playwright.Tracing; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.ColorScheme; +import com.microsoft.playwright.options.WaitUntilState; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class BaseTest { + private Playwright playwright; + private Browser browser; + private BrowserContext context; + private TutorialApp application; + private final List browserErrors = new ArrayList<>(); + + protected Page page; + + protected abstract String stepDirectory(); + + @BeforeAll + void startApplicationAndBrowser() throws Exception { + application = TutorialApp.start(stepDirectory()); + playwright = Playwright.create(); + browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + PlaywrightAssertions.setDefaultAssertionTimeout(10_000); + } + + @BeforeEach + void createBrowserContext() { + browserErrors.clear(); + context = browser.newContext(new Browser.NewContextOptions() + .setViewportSize(1440, 900) + .setDeviceScaleFactor(1) + .setColorScheme(ColorScheme.LIGHT) + .setLocale("en-US") + .setTimezoneId("UTC") + .setIgnoreHTTPSErrors(true)); + context.tracing().start(new Tracing.StartOptions() + .setScreenshots(true) + .setSnapshots(true) + .setSources(true)); + + page = context.newPage(); + page.onPageError(error -> browserErrors.add("pageerror: " + error)); + page.onConsoleMessage(this::recordConsoleError); + page.onRequestFailed(this::recordRequestFailure); + } + + @AfterEach + void closeBrowserContext(TestInfo testInfo) throws IOException { + List errorsBeforeClose = List.copyOf(browserErrors); + if (context != null) { + Path traceDirectory = Path.of("target", "playwright-traces"); + Files.createDirectories(traceDirectory); + String testName = (getClass().getSimpleName() + "-" + testInfo.getDisplayName()) + .replaceAll("[^a-zA-Z0-9.-]", "-"); + context.tracing().stop(new Tracing.StopOptions() + .setPath(traceDirectory.resolve(testName + ".zip"))); + context.close(); + context = null; + } + assertTrue(errorsBeforeClose.isEmpty(), + () -> "Browser runtime/network errors:\n" + String.join("\n", errorsBeforeClose)); + } + + @AfterAll + void stopApplicationAndBrowser() { + if (browser != null) { + browser.close(); + } + if (playwright != null) { + playwright.close(); + } + if (application != null) { + application.close(); + } + } + + protected void openApplication() { + openApplication("/"); + } + + protected void openApplication(String path) { + String normalizedPath = path.startsWith("/") ? path : "/" + path; + page.navigate(application.baseUrl() + normalizedPath, + new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + } + + protected Locator customerTable() { + return page.locator("dwc-table"); + } + + protected Locator expectCustomerTable() { + Locator table = customerTable(); + assertThat(table).isVisible(); + assertThat(table).containsText("First Name"); + assertThat(table).containsText("Last Name"); + assertThat(table).containsText("Company"); + assertThat(table).containsText("Country"); + assertThat(table).containsText("Alice"); + assertThat(table).containsText("TechCorp"); + return table; + } + + private void recordConsoleError(ConsoleMessage message) { + if ("error".equals(message.type())) { + browserErrors.add("console: " + message.text()); + } + } + + private void recordRequestFailure(Request request) { + String failure = request.failure(); + boolean cancelledWebforjPoll = "net::ERR_ABORTED".equals(failure) + && request.url().contains("/webforjServlet/webapprmi"); + if (!cancelledWebforjPoll) { + browserErrors.add( + "requestfailed: " + request.method() + " " + request.url() + " (" + failure + ")"); + } + } +} diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step1IT.java b/e2e/src/test/java/com/webforj/tutorial/e2e/Step1IT.java new file mode 100644 index 0000000..6e80204 --- /dev/null +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/Step1IT.java @@ -0,0 +1,30 @@ +package com.webforj.tutorial.e2e; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.AriaRole; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class Step1IT extends BaseTest { + @Override + protected String stepDirectory() { + return "1-creating-a-basic-app"; + } + + @Test + void rendersBasicApplicationAndOpensInformationDialog() throws IOException { + openApplication(); + + assertThat(page.getByText("Tutorial App!", new Page.GetByTextOptions().setExact(true))).isVisible(); + var infoButton = page.getByRole( + AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Info").setExact(true)); + assertThat(infoButton).isVisible(); + infoButton.click(); + + assertThat(page.getByRole(AriaRole.DIALOG)).isVisible(); + assertThat(page.getByText("This is a tutorial!", new Page.GetByTextOptions().setExact(true))).isVisible(); + VisualAssertions.assertScreenshot(page, "basic-application-dialog.png"); + } +} diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step2IT.java b/e2e/src/test/java/com/webforj/tutorial/e2e/Step2IT.java new file mode 100644 index 0000000..cc8b68c --- /dev/null +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/Step2IT.java @@ -0,0 +1,59 @@ +package com.webforj.tutorial.e2e; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.AriaRole; +import com.microsoft.playwright.options.BoundingBox; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class Step2IT extends BaseTest { + @Override + protected String stepDirectory() { + return "2-working-with-data"; + } + + @Test + void rendersSeededDataDialogAndSorting() throws IOException { + openApplication(); + + assertThat(page.getByText("Tutorial App!", new Page.GetByTextOptions().setExact(true))).isVisible(); + Locator table = expectCustomerTable(); + assertThat(table).containsText("John"); + assertThat(table).containsText("Innovatech"); + + page.getByRole(AriaRole.BUTTON, + new Page.GetByRoleOptions().setName("Info").setExact(true)).click(); + assertThat(page.getByText("This is a tutorial!", new Page.GetByTextOptions().setExact(true))).isVisible(); + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("OK")).click(); + assertThat(page.getByRole(AriaRole.DIALOG)).isHidden(); + VisualAssertions.assertScreenshot(page, "customer-table.png"); + + Locator firstNameHeader = page.locator( + "dwc-table [part~='cell-header'][data-column='firstName']"); + firstNameHeader.click(); + waitForRowOrder("Emma", "John"); + firstNameHeader.click(); + waitForRowOrder("John", "Emma"); + } + + private void waitForRowOrder(String first, String second) { + long deadline = System.nanoTime() + 10_000_000_000L; + while (System.nanoTime() < deadline) { + if (rowTop(first) < rowTop(second)) { + return; + } + page.waitForTimeout(100); + } + fail("Expected row " + first + " to appear above " + second); + } + + private double rowTop(String name) { + Locator cell = page.getByText(name, new Page.GetByTextOptions().setExact(true)); + BoundingBox box = cell.boundingBox(); + return box == null ? Double.MAX_VALUE : box.y; + } +} diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step3IT.java b/e2e/src/test/java/com/webforj/tutorial/e2e/Step3IT.java new file mode 100644 index 0000000..69e92d9 --- /dev/null +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/Step3IT.java @@ -0,0 +1,46 @@ +package com.webforj.tutorial.e2e; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +import com.microsoft.playwright.options.AriaRole; +import java.io.IOException; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +class Step3IT extends BaseTest { + @Override + protected String stepDirectory() { + return "3-routing-and-composites"; + } + + @Test + void createsCustomerAndReturnsToTable() throws IOException { + openApplication(); + expectCustomerTable(); + + page.getByRole(AriaRole.BUTTON, new com.microsoft.playwright.Page.GetByRoleOptions() + .setName("Add Customer")).click(); + assertThat(page).hasURL(Pattern.compile("/customer$")); + assertThat(page.getByRole(AriaRole.BUTTON, + new com.microsoft.playwright.Page.GetByRoleOptions().setName("Submit"))).isVisible(); + assertThat(page.getByRole(AriaRole.BUTTON, + new com.microsoft.playwright.Page.GetByRoleOptions().setName("Cancel"))).isVisible(); + + page.getByLabel("First Name").fill("Ada"); + page.getByLabel("Last Name").fill("Lovelace"); + page.getByLabel("Company").fill("Analytical Engines"); + VisualAssertions.assertScreenshot(page, "completed-customer-form.png"); + + page.getByRole(AriaRole.BUTTON, + new com.microsoft.playwright.Page.GetByRoleOptions().setName("Submit")).click(); + assertThat(page).hasURL(Pattern.compile("/$")); + assertThat(customerTable()).containsText("Ada"); + assertThat(customerTable()).containsText("Analytical Engines"); + + page.getByRole(AriaRole.BUTTON, + new com.microsoft.playwright.Page.GetByRoleOptions().setName("Add Customer")).click(); + page.getByRole(AriaRole.BUTTON, + new com.microsoft.playwright.Page.GetByRoleOptions().setName("Cancel")).click(); + assertThat(page).hasURL(Pattern.compile("/$")); + } +} diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step4IT.java b/e2e/src/test/java/com/webforj/tutorial/e2e/Step4IT.java new file mode 100644 index 0000000..ccbd935 --- /dev/null +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/Step4IT.java @@ -0,0 +1,57 @@ +package com.webforj.tutorial.e2e; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.AriaRole; +import java.io.IOException; +import java.util.regex.Pattern; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +@TestMethodOrder(OrderAnnotation.class) +class Step4IT extends BaseTest { + @Override + protected String stepDirectory() { + return "4-observers-and-route-parameters"; + } + + @Test + @Order(1) + void showsRoutedCustomerFormConsistently() throws IOException { + openApplication("/customer/1"); + assertThat(page.getByLabel("First Name")).hasValue("Alice"); + assertThat(page.getByLabel("Last Name")).hasValue("Smith"); + assertThat(page.getByLabel("Company")).hasValue("TechCorp"); + VisualAssertions.assertScreenshot(page, "edit-customer-route.png"); + } + + @Test + @Order(2) + void loadsCustomerFromRouteAndSavesEdits() { + openApplication(); + expectCustomerTable(); + + page.getByText("Alice", new Page.GetByTextOptions().setExact(true)).click(); + assertThat(page).hasURL(Pattern.compile("/customer/1$")); + assertThat(page.getByLabel("First Name")).hasValue("Alice"); + assertThat(page.getByLabel("Last Name")).hasValue("Smith"); + + var company = page.getByLabel("Company"); + company.click(); + company.press("Control+A"); + company.pressSequentially("Updated TechCorp", new com.microsoft.playwright.Locator.PressSequentiallyOptions() + .setDelay(25)); + assertThat(company).hasValue("Updated TechCorp"); + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit")).click(); + assertThat(page).hasURL(Pattern.compile("/$")); + assertThat(page.getByRole(AriaRole.CELL, + new Page.GetByRoleOptions().setName("Updated TechCorp").setExact(true))).isVisible(); + + openApplication("/customer/999999"); + assertThat(page).hasURL(Pattern.compile("/$")); + assertThat(customerTable()).isVisible(); + } +} diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step5IT.java b/e2e/src/test/java/com/webforj/tutorial/e2e/Step5IT.java new file mode 100644 index 0000000..3cc9f1a --- /dev/null +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/Step5IT.java @@ -0,0 +1,52 @@ +package com.webforj.tutorial.e2e; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.AriaRole; +import java.io.IOException; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +class Step5IT extends BaseTest { + @Override + protected String stepDirectory() { + return "5-validating-and-binding-data"; + } + + @Test + void bindsAndValidatesCustomerBeforeSubmission() throws IOException { + openApplication(); + expectCustomerTable(); + + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Add Customer")).click(); + var submit = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit")); + assertThat(submit).isEnabled(); + + page.getByLabel("First Name").fill("Grace"); + submit.click(); + assertThat(page).hasURL(Pattern.compile("/customer$")); + var lastNameError = page.getByText( + "Customer last name is required", new Page.GetByTextOptions().setExact(true)); + assertThat(lastNameError).isVisible(); + page.getByLabel("Last Name").fill("Hopper"); + page.getByLabel("Company").fill("Compiler Systems"); + assertThat(lastNameError).isHidden(); + assertThat(submit).isEnabled(); + VisualAssertions.assertScreenshot(page, "valid-customer-form.png"); + + submit.click(); + assertThat(page).hasURL(Pattern.compile("/$")); + assertThat(customerTable()).containsText("Grace"); + assertThat(customerTable()).containsText("Compiler Systems"); + } + + @Test + void loadsExistingCustomerIntoBindingContext() { + openApplication("/customer/1"); + assertThat(page.getByLabel("First Name")).hasValue("Alice"); + assertThat(page.getByLabel("Last Name")).hasValue("Smith"); + assertThat(page.getByRole(AriaRole.BUTTON, + new Page.GetByRoleOptions().setName("Submit"))).isEnabled(); + } +} diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step6IT.java b/e2e/src/test/java/com/webforj/tutorial/e2e/Step6IT.java new file mode 100644 index 0000000..c57d3b1 --- /dev/null +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/Step6IT.java @@ -0,0 +1,85 @@ +package com.webforj.tutorial.e2e; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.AriaRole; +import java.io.IOException; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +class Step6IT extends BaseTest { + @Override + protected String stepDirectory() { + return "6-integrating-an-app-layout"; + } + + @Test + void providesApplicationLayoutNavigation() throws IOException { + openApplication(); + expectCustomerTable(); + assertThat(page.getByRole(AriaRole.HEADING, + new Page.GetByRoleOptions().setName("Customer Table").setLevel(1))).isVisible(); + VisualAssertions.assertScreenshot(page, "dashboard-layout.png"); + + page.getByText("About", new Page.GetByTextOptions().setExact(true)).click(); + assertThat(page).hasURL(Pattern.compile("/about$")); + assertThat(page.getByRole(AriaRole.HEADING, + new Page.GetByRoleOptions().setName("About").setLevel(1))).isVisible(); + assertThat(page.getByRole(AriaRole.HEADING, + new Page.GetByRoleOptions().setName("Customer Manager").setLevel(2)).last()).isVisible(); + + page.getByText("Dashboard", new Page.GetByTextOptions().setExact(true)).click(); + assertThat(page).hasURL(Pattern.compile("/$")); + assertThat(customerTable()).isVisible(); + + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Add Customer")).click(); + assertThat(page).hasURL(Pattern.compile("/customer$")); + assertThat(page.getByRole(AriaRole.HEADING, + new Page.GetByRoleOptions().setName("Customer Form").setLevel(1))).isVisible(); + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Cancel")).click(); + assertThat(page).hasURL(Pattern.compile("/$")); + } + + @Test + void keepsApplicationLayoutUsableAtNarrowViewport() throws IOException { + page.setViewportSize(390, 844); + openApplication(); + + assertThat(page.getByRole(AriaRole.HEADING, + new Page.GetByRoleOptions().setName("Customer Table").setLevel(1))).isVisible(); + assertThat(page.getByRole(AriaRole.BUTTON, + new Page.GetByRoleOptions().setName("Add Customer"))).isVisible(); + VisualAssertions.assertScreenshot(page, "dashboard-mobile.png"); + + Locator dashboardLink = page.getByRole(AriaRole.LINK, + new Page.GetByRoleOptions().setName(Pattern.compile("Dashboard"))); + waitForViewportState(dashboardLink, false); + assertFalse(isInViewport(dashboardLink), "Dashboard link should start outside the mobile viewport"); + page.getByRole(AriaRole.BUTTON, + new Page.GetByRoleOptions().setName(Pattern.compile("menu", Pattern.CASE_INSENSITIVE))).click(); + waitForViewportState(dashboardLink, true); + assertTrue(isInViewport(dashboardLink), "Dashboard link should enter the viewport when the drawer opens"); + } + + private void waitForViewportState(Locator locator, boolean expected) { + long deadline = System.nanoTime() + 10_000_000_000L; + while (System.nanoTime() < deadline) { + if (isInViewport(locator) == expected) { + return; + } + page.waitForTimeout(100); + } + } + + private boolean isInViewport(Locator locator) { + return Boolean.TRUE.equals(locator.evaluate("element => {" + + "const rect = element.getBoundingClientRect();" + + "return rect.bottom > 0 && rect.right > 0 " + + "&& rect.top < window.innerHeight && rect.left < window.innerWidth;" + + "}")); + } +} diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/TutorialApp.java b/e2e/src/test/java/com/webforj/tutorial/e2e/TutorialApp.java new file mode 100644 index 0000000..15c5d70 --- /dev/null +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/TutorialApp.java @@ -0,0 +1,183 @@ +package com.webforj.tutorial.e2e; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.TimeUnit; + +final class TutorialApp implements AutoCloseable { + private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(3); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .build(); + + private final Process process; + private final String baseUrl; + private final Path logFile; + + private TutorialApp(Process process, String baseUrl, Path logFile) { + this.process = process; + this.baseUrl = baseUrl; + this.logFile = logFile; + } + + static TutorialApp start(String stepDirectory) throws Exception { + requireDocker(); + + Path repositoryRoot = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); + Path stepPath = repositoryRoot.resolve(stepDirectory).normalize(); + if (!stepPath.startsWith(repositoryRoot) || !Files.isDirectory(stepPath)) { + throw new IllegalArgumentException("Invalid tutorial step directory: " + stepDirectory); + } + + Path jar = findApplicationJar(stepPath.resolve("target")); + int port = findFreePort(); + String baseUrl = "http://127.0.0.1:" + port; + Path logFile = repositoryRoot.resolve("target/e2e-artifacts") + .resolve(stepDirectory) + .resolve("server.log"); + Files.createDirectories(logFile.getParent()); + + String javaExecutable = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + ProcessBuilder processBuilder = new ProcessBuilder( + javaExecutable, + "-jar", + jar.toString(), + "--server.port=" + port, + "--server.address=127.0.0.1", + "--spring.jpa.hibernate.ddl-auto=create-drop", + "--webforj.devtools.browser.open=false", + "--webforj.devtools.livereload.enabled=false", + "--webforj.devtools.livereload.static-resources-enabled=false", + "--webforj.devtools.craftforj.enabled=false", + "--webforj.debug=false"); + processBuilder.directory(stepPath.toFile()); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(logFile.toFile()); + + Process process = processBuilder.start(); + TutorialApp app = new TutorialApp(process, baseUrl, logFile); + try { + app.waitUntilReady(); + return app; + } catch (Exception exception) { + app.close(); + throw exception; + } + } + + String baseUrl() { + return baseUrl; + } + + private static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException( + "E2E tests may only run in Docker. Run `mvn verify` from the repository root."); + } + } + + private static Path findApplicationJar(Path targetDirectory) throws IOException { + if (!Files.isDirectory(targetDirectory)) { + throw new IllegalStateException("Application target directory does not exist: " + targetDirectory); + } + + try (var files = Files.list(targetDirectory)) { + List jars = files + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".jar")) + .filter(path -> !path.getFileName().toString().endsWith(".jar.original")) + .sorted(Comparator.comparing(Path::toString)) + .toList(); + if (jars.size() != 1) { + throw new IllegalStateException( + "Expected exactly one executable application JAR in " + targetDirectory + + ", found " + jars.size()); + } + return jars.getFirst(); + } + } + + private static int findFreePort() throws IOException { + for (int preferredPort : List.of(8080, 8090)) { + try (ServerSocket ignored = new ServerSocket(preferredPort)) { + return preferredPort; + } catch (IOException ignored) { + // Try the next preferred port. + } + } + + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void waitUntilReady() throws Exception { + long deadline = System.nanoTime() + STARTUP_TIMEOUT.toNanos(); + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/")) + .timeout(Duration.ofSeconds(3)) + .GET() + .build(); + + while (System.nanoTime() < deadline) { + if (!process.isAlive()) { + throw new IllegalStateException( + "Application exited before becoming ready.\n" + tailLog()); + } + + try { + HttpResponse response = HTTP_CLIENT.send( + request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() < 500) { + return; + } + } catch (IOException ignored) { + // The server is still starting. + } + + Thread.sleep(500); + } + + throw new IllegalStateException( + "Application did not become ready within " + STARTUP_TIMEOUT + ".\n" + tailLog()); + } + + private String tailLog() { + try { + List lines = Files.readAllLines(logFile, StandardCharsets.UTF_8); + int start = Math.max(0, lines.size() - 100); + return "Server log: " + logFile + "\n" + String.join("\n", lines.subList(start, lines.size())); + } catch (IOException exception) { + return "Could not read server log " + logFile + ": " + exception.getMessage(); + } + } + + @Override + public void close() { + if (!process.isAlive()) { + return; + } + + process.descendants().forEach(ProcessHandle::destroy); + process.destroy(); + try { + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } +} diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/VisualAssertions.java b/e2e/src/test/java/com/webforj/tutorial/e2e/VisualAssertions.java new file mode 100644 index 0000000..f2383e8 --- /dev/null +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/VisualAssertions.java @@ -0,0 +1,125 @@ +package com.webforj.tutorial.e2e; + +import static org.junit.jupiter.api.Assertions.fail; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.imageio.ImageIO; + +final class VisualAssertions { + private static final int DEFAULT_MAX_DIFFERENT_PIXELS = 500; + private static final int CHANNEL_THRESHOLD = 51; + + private VisualAssertions() { + } + + static void assertScreenshot(Page page, String name) throws IOException { + requirePngName(name); + page.evaluate("() => document.fonts.ready"); + + byte[] screenshot = page.screenshot(new Page.ScreenshotOptions() + .setAnimations(ScreenshotAnimations.DISABLED) + .setCaret(ScreenshotCaret.HIDE) + .setScale(ScreenshotScale.CSS)); + + Path baselineDirectory = Path.of(requiredProperty("visual.baseline.dir")); + Path artifactDirectory = Path.of(requiredProperty("visual.artifact.dir")); + Path baseline = baselineDirectory.resolve(name); + + if (Boolean.getBoolean("updateScreenshots")) { + requireDocker(); + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing Docker screenshot baseline " + baseline + + ". Generate it with `mvn verify -DupdateScreenshots=true`."); + } + + BufferedImage expected = ImageIO.read(baseline.toFile()); + BufferedImage actual = ImageIO.read(new ByteArrayInputStream(screenshot)); + if (expected == null || actual == null) { + fail("Could not decode screenshot baseline or actual image for " + name); + } + + if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { + writeFailureArtifacts(artifactDirectory, name, screenshot, null); + fail("Screenshot dimensions differ for " + name + ": expected " + + expected.getWidth() + "x" + expected.getHeight() + ", actual " + + actual.getWidth() + "x" + actual.getHeight()); + } + + BufferedImage diff = new BufferedImage(actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); + int differentPixels = 0; + for (int y = 0; y < actual.getHeight(); y++) { + for (int x = 0; x < actual.getWidth(); x++) { + int expectedRgb = expected.getRGB(x, y); + int actualRgb = actual.getRGB(x, y); + if (isDifferent(expectedRgb, actualRgb)) { + differentPixels++; + diff.setRGB(x, y, Color.MAGENTA.getRGB()); + } else { + Color pixel = new Color(actualRgb, true); + int gray = (pixel.getRed() + pixel.getGreen() + pixel.getBlue()) / 3; + diff.setRGB(x, y, new Color(gray, gray, gray, 110).getRGB()); + } + } + } + + int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); + if (differentPixels > maximum) { + writeFailureArtifacts(artifactDirectory, name, screenshot, diff); + fail("Screenshot mismatch for " + name + ": " + differentPixels + + " pixels differ (maximum " + maximum + "). See " + artifactDirectory); + } + } + + private static boolean isDifferent(int expectedRgb, int actualRgb) { + Color expected = new Color(expectedRgb, true); + Color actual = new Color(actualRgb, true); + return Math.abs(expected.getRed() - actual.getRed()) > CHANNEL_THRESHOLD + || Math.abs(expected.getGreen() - actual.getGreen()) > CHANNEL_THRESHOLD + || Math.abs(expected.getBlue() - actual.getBlue()) > CHANNEL_THRESHOLD + || Math.abs(expected.getAlpha() - actual.getAlpha()) > CHANNEL_THRESHOLD; + } + + private static void writeFailureArtifacts( + Path artifactDirectory, String name, byte[] screenshot, BufferedImage diff) throws IOException { + Files.createDirectories(artifactDirectory); + String stem = name.substring(0, name.length() - ".png".length()); + Files.write(artifactDirectory.resolve(stem + "-actual.png"), screenshot); + if (diff != null) { + ImageIO.write(diff, "png", artifactDirectory.resolve(stem + "-diff.png").toFile()); + } + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required system property: " + name); + } + return value; + } + + private static void requirePngName(String name) { + if (!name.matches("[a-z0-9-]+\\.png")) { + throw new IllegalArgumentException("Screenshot name must be a simple kebab-case PNG filename: " + name); + } + } + + private static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException("Screenshot baselines may only be generated in Docker."); + } + } +} diff --git a/e2e/src/test/resources/screenshots/basic-application-dialog.png b/e2e/src/test/resources/screenshots/basic-application-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..540f487bf25693488af94ca34d29cae2c384453f GIT binary patch literal 17215 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
~v`_U>QQWLyjR@-y4^vxh%k25Wp@!}+7*`XRtj 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

EA=A@lbLQORi4diC05PqusMqiwu2 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 literal 0 HcmV?d00001 diff --git a/e2e/src/test/resources/screenshots/customer-table.png b/e2e/src/test/resources/screenshots/customer-table.png new file mode 100644 index 0000000000000000000000000000000000000000..b66f7535088eb3aa30fdbce8d752d9991118cf1e GIT binary patch literal 34907 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 literal 0 HcmV?d00001 diff --git a/e2e/src/test/resources/screenshots/dashboard-layout.png b/e2e/src/test/resources/screenshots/dashboard-layout.png new file mode 100644 index 0000000000000000000000000000000000000000..029b429a2a8b9fdc82873ad963398fa3ec5b9b54 GIT binary patch literal 49066 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

r4-4*$53~h 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 aOCn!w{^5)baCHy;@MpW5Rc819@;?ANn(BrC literal 0 HcmV?d00001 diff --git a/e2e/src/test/resources/screenshots/dashboard-mobile.png b/e2e/src/test/resources/screenshots/dashboard-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..cba2a4d371653bbceb8ad171b51fc9a29d947aad GIT binary patch literal 29744 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 literal 0 HcmV?d00001 diff --git a/e2e/src/test/resources/screenshots/valid-customer-form.png b/e2e/src/test/resources/screenshots/valid-customer-form.png new file mode 100644 index 0000000000000000000000000000000000000000..d402c85816c7c099c4d84808df45748178216d61 GIT binary patch literal 22249 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 literal 0 HcmV?d00001 diff --git a/e2e/tests/step-1.spec.ts b/e2e/tests/step-1.spec.ts deleted file mode 100644 index ffc2653..0000000 --- a/e2e/tests/step-1.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { expect, test } from './support/fixtures'; -import { openApplication } from './support/app'; - -test('renders the basic application and opens the information dialog', async ({ page, browserErrors }) => { - await openApplication(page); - - await expect(page.getByText('Tutorial App!', { exact: true })).toBeVisible(); - - const infoButton = page.getByRole('button', { name: 'Info', exact: true }); - await expect(infoButton).toBeVisible(); - await infoButton.click(); - - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - await expect(page.getByText('This is a tutorial!', { exact: true })).toBeVisible(); - await expect(page).toHaveScreenshot('basic-application-dialog.png'); - - expect(browserErrors).toEqual([]); -}); diff --git a/e2e/tests/step-1.spec.ts-snapshots/basic-application-dialog-chromium-win32.png b/e2e/tests/step-1.spec.ts-snapshots/basic-application-dialog-chromium-win32.png deleted file mode 100644 index 4f7189cd6bdccc47bca8d0f2d0aa8402f862b780..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15382 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 DmYtIv diff --git a/e2e/tests/step-2.spec.ts b/e2e/tests/step-2.spec.ts deleted file mode 100644 index cb13483..0000000 --- a/e2e/tests/step-2.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { expect, test } from './support/fixtures'; -import { expectCustomerTable, openApplication } from './support/app'; - -test('renders seeded customer data and keeps the information dialog functional', async ({ page, browserErrors }) => { - await openApplication(page); - - await expect(page.getByText('Tutorial App!', { exact: true })).toBeVisible(); - const table = await expectCustomerTable(page); - await expect(table).toContainText('John'); - await expect(table).toContainText('Innovatech'); - - await page.getByRole('button', { name: 'Info', exact: true }).click(); - await expect(page.getByText('This is a tutorial!', { exact: true })).toBeVisible(); - await page.getByRole('button', { name: 'OK' }).click(); - - await expect(page.getByRole('dialog')).toBeHidden(); - await expect(page).toHaveScreenshot('customer-table.png'); - - const firstNameHeader = page.getByRole('columnheader', { name: /^First Name/ }); - const rowTop = async (name: string) => - (await page.getByRole('cell', { name, exact: true }).boundingBox())?.y ?? Number.MAX_SAFE_INTEGER; - - await firstNameHeader.click(); - await expect.poll(async () => await rowTop('Emma') < await rowTop('John')).toBe(true); - await firstNameHeader.click(); - await expect.poll(async () => await rowTop('John') < await rowTop('Emma')).toBe(true); - - expect(browserErrors).toEqual([]); -}); diff --git a/e2e/tests/step-2.spec.ts-snapshots/customer-table-chromium-win32.png b/e2e/tests/step-2.spec.ts-snapshots/customer-table-chromium-win32.png deleted file mode 100644 index 04aed2a4eacfc0a2f4596b16385583c5f950acf3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22237 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 F{{S!0n}YxV diff --git a/e2e/tests/step-3.spec.ts b/e2e/tests/step-3.spec.ts deleted file mode 100644 index 469d160..0000000 --- a/e2e/tests/step-3.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { expect, test } from './support/fixtures'; -import { customerTable, expectCustomerTable, openApplication } from './support/app'; - -test('navigates to the customer form, creates a customer, and returns to the table', async ({ page, browserErrors }) => { - await openApplication(page); - await expectCustomerTable(page); - - await page.getByRole('button', { name: 'Add Customer' }).click(); - await expect(page).toHaveURL(/\/customer$/); - await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible(); - - await page.getByLabel('First Name').fill('Ada'); - await page.getByLabel('Last Name').fill('Lovelace'); - await page.getByLabel('Company').fill('Analytical Engines'); - await expect(page).toHaveScreenshot('completed-customer-form.png'); - - await page.getByRole('button', { name: 'Submit' }).click(); - await expect(page).toHaveURL(/\/$/); - await expect(customerTable(page)).toContainText('Ada'); - await expect(customerTable(page)).toContainText('Analytical Engines'); - - await page.getByRole('button', { name: 'Add Customer' }).click(); - await page.getByRole('button', { name: 'Cancel' }).click(); - await expect(page).toHaveURL(/\/$/); - expect(browserErrors).toEqual([]); -}); diff --git a/e2e/tests/step-3.spec.ts-snapshots/completed-customer-form-chromium-win32.png b/e2e/tests/step-3.spec.ts-snapshots/completed-customer-form-chromium-win32.png deleted file mode 100644 index 07eb8cb417c1846ab43ba603aaa8c227674b2f8d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19411 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 diff --git a/e2e/tests/step-4.spec.ts b/e2e/tests/step-4.spec.ts deleted file mode 100644 index 69e038c..0000000 --- a/e2e/tests/step-4.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { expect, test } from './support/fixtures'; -import { customerTable, expectCustomerTable, openApplication } from './support/app'; - -test('shows the routed customer form consistently', async ({ page }) => { - await openApplication(page, '/customer/1'); - await expect(page.getByLabel('First Name')).toHaveValue('Alice'); - await expect(page.getByLabel('Last Name')).toHaveValue('Smith'); - await expect(page.getByLabel('Company')).toHaveValue('TechCorp'); - await expect(page).toHaveScreenshot('edit-customer-route.png'); -}); - -test('loads a customer through its route parameter and saves edits', async ({ page, browserErrors }) => { - await openApplication(page); - await expectCustomerTable(page); - - await page.getByText('Alice', { exact: true }).click(); - await expect(page).toHaveURL(/\/customer\/1$/); - await expect(page.getByLabel('First Name')).toHaveValue('Alice'); - await expect(page.getByLabel('Last Name')).toHaveValue('Smith'); - - const company = page.getByLabel('Company'); - await company.click(); - await company.press('ControlOrMeta+A'); - await company.pressSequentially('Updated TechCorp', { delay: 25 }); - await expect(company).toHaveValue('Updated TechCorp'); - await page.getByRole('button', { name: 'Submit' }).click(); - await expect(page).toHaveURL(/\/$/); - await expect(page.getByRole('cell', { name: 'Updated TechCorp', exact: true })).toBeVisible(); - - await openApplication(page, '/customer/999999'); - await expect(page).toHaveURL(/\/$/); - await expect(customerTable(page)).toBeVisible(); - expect(browserErrors).toEqual([]); -}); diff --git a/e2e/tests/step-4.spec.ts-snapshots/edit-customer-route-chromium-win32.png b/e2e/tests/step-4.spec.ts-snapshots/edit-customer-route-chromium-win32.png deleted file mode 100644 index 0564a50f96837cce55acc8a81e9c9811b5342a30..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17824 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 Vm?3%LMsN?Ht*QTek@~~u{|l { - await openApplication(page); - await expectCustomerTable(page); - - await page.getByRole('button', { name: 'Add Customer' }).click(); - const submit = page.getByRole('button', { name: 'Submit' }); - await expect(submit).toBeEnabled(); - - await page.getByLabel('First Name').fill('Grace'); - await submit.click(); - await expect(page).toHaveURL(/\/customer$/); - await expect(page.getByText('Customer last name is required', { exact: true })).toBeVisible(); - await page.getByLabel('Last Name').fill('Hopper'); - await page.getByLabel('Company').fill('Compiler Systems'); - await expect(page.getByText('Customer last name is required', { exact: true })).toBeHidden(); - await expect(submit).toBeEnabled(); - await expect(page).toHaveScreenshot('valid-customer-form.png'); - - await submit.click(); - await expect(page).toHaveURL(/\/$/); - await expect(customerTable(page)).toContainText('Grace'); - await expect(customerTable(page)).toContainText('Compiler Systems'); - expect(browserErrors).toEqual([]); -}); - -test('loads an existing customer into the binding context', async ({ page }) => { - await openApplication(page, '/customer/1'); - await expect(page.getByLabel('First Name')).toHaveValue('Alice'); - await expect(page.getByLabel('Last Name')).toHaveValue('Smith'); - await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled(); -}); diff --git a/e2e/tests/step-5.spec.ts-snapshots/valid-customer-form-chromium-win32.png b/e2e/tests/step-5.spec.ts-snapshots/valid-customer-form-chromium-win32.png deleted file mode 100644 index 162f61b535faf1c2f60d3400d1b2b2f48276dcbb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19691 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 { - await openApplication(page); - await expectCustomerTable(page); - await expect(page.getByRole('heading', { name: 'Customer Table', level: 1 })).toBeVisible(); - await expect(page).toHaveScreenshot('dashboard-layout.png'); - - await page.getByText('About', { exact: true }).click(); - await expect(page).toHaveURL(/\/about$/); - await expect(page.getByRole('heading', { name: 'About', level: 1 })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Customer Manager', level: 2 }).last()).toBeVisible(); - - await page.getByText('Dashboard', { exact: true }).click(); - await expect(page).toHaveURL(/\/$/); - await expect(customerTable(page)).toBeVisible(); - - await page.getByRole('button', { name: 'Add Customer' }).click(); - await expect(page).toHaveURL(/\/customer$/); - await expect(page.getByRole('heading', { name: 'Customer Form', level: 1 })).toBeVisible(); - await page.getByRole('button', { name: 'Cancel' }).click(); - await expect(page).toHaveURL(/\/$/); - expect(browserErrors).toEqual([]); -}); - -test('keeps the application layout usable at a narrow viewport', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }); - await openApplication(page); - - await expect(page.getByRole('heading', { name: 'Customer Table', level: 1 })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Add Customer' })).toBeVisible(); - await expect(page).toHaveScreenshot('dashboard-mobile.png'); - - const dashboardLink = page.getByRole('link', { name: /Dashboard/ }); - await expect(dashboardLink).not.toBeInViewport(); - await page.getByRole('button', { name: /menu/i }).click(); - await expect(dashboardLink).toBeInViewport(); -}); diff --git a/e2e/tests/step-6.spec.ts-snapshots/dashboard-layout-chromium-win32.png b/e2e/tests/step-6.spec.ts-snapshots/dashboard-layout-chromium-win32.png deleted file mode 100644 index 7fd9f9506c1d23556da3a2fa10a700d745a60f16..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33686 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 diff --git a/e2e/tests/step-6.spec.ts-snapshots/dashboard-mobile-chromium-win32.png b/e2e/tests/step-6.spec.ts-snapshots/dashboard-mobile-chromium-win32.png deleted file mode 100644 index 87fe5d32ddfb7336634cfca9f5533defcb5506a4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18729 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 - browserErrors: [async ({ page }, use) => { - const browserErrors: string[] = []; - - page.on('pageerror', error => { - browserErrors.push(`pageerror: ${error.message}`); - }); - page.on('console', message => { - if (message.type() === 'error') { - browserErrors.push(`console: ${message.text()}`); - } - }); - page.on('requestfailed', request => { - browserErrors.push( - `requestfailed: ${request.method()} ${request.url()} (${request.failure()?.errorText ?? 'unknown error'})`); - }); - - await use(browserErrors); - - expect.soft(browserErrors, 'The browser should not report runtime or network errors').toEqual([]); - }, { auto: true }] -}); - -export { expect } from '@playwright/test'; diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 3edc818..0000000 --- a/package-lock.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "name": "webforj-tutorial-e2e", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "webforj-tutorial-e2e", - "version": "1.0.0", - "devDependencies": { - "@playwright/test": "1.62.1" - } - }, - "node_modules/@playwright/test": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", - "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.62.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/playwright": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", - "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.62.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 474a324..0000000 --- a/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "webforj-tutorial-e2e", - "version": "1.0.0", - "private": true, - "description": "End-to-end and visual regression tests for the webforJ tutorial steps", - "scripts": { - "e2e": "node e2e/scripts/run-steps.mjs", - "e2e:headed": "node e2e/scripts/run-steps.mjs --headed", - "e2e:update": "node e2e/scripts/run-steps.mjs --update-snapshots", - "e2e:install": "playwright install chromium" - }, - "devDependencies": { - "@playwright/test": "1.62.1" - } -} diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index a458b1f..0000000 --- a/playwright.config.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -const step = process.env.E2E_STEP ?? '1'; -const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:8080'; - -export default defineConfig({ - testDir: './e2e/tests', - testMatch: `step-${step}.spec.ts`, - outputDir: `test-results/step-${step}/playwright`, - fullyParallel: false, - workers: 1, - retries: process.env.CI ? 1 : 0, - forbidOnly: Boolean(process.env.CI), - timeout: 45_000, - expect: { - timeout: 10_000, - toHaveScreenshot: { - animations: 'disabled', - caret: 'hide', - maxDiffPixels: 200, - scale: 'css' - } - }, - reporter: [ - ['list'], - ['html', { outputFolder: `playwright-report/step-${step}`, open: 'never' }] - ], - use: { - baseURL, - colorScheme: 'light', - locale: 'en-US', - timezoneId: 'UTC', - viewport: { width: 1440, height: 900 }, - deviceScaleFactor: 1, - screenshot: 'only-on-failure', - trace: 'retain-on-failure', - video: 'retain-on-failure' - }, - projects: [ - { - name: 'chromium', - use: { - ...devices['Desktop Chrome'], - viewport: { width: 1440, height: 900 } - } - } - ] -}); diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..3b6bd01 --- /dev/null +++ b/pom.xml @@ -0,0 +1,323 @@ + + + 4.0.0 + + com.webforj.tutorial + tutorial-e2e + 1.0-SNAPSHOT + webforJ Tutorial End-to-End Tests + + + UTF-8 + 21 + 1.50.0 + 5.11.4 + webforj-tutorial-e2e:playwright-${playwright.version} + *IT + false + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + true + + + + + + + + + docker-e2e + + + !docker.e2e.container + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + docker-e2e-build + pre-integration-test + + exec + + + docker + + build + -t + ${docker.image} + . + + + + + docker-e2e + integration-test + + exec + + + docker + + run + --rm + --init + --ipc=host + -e + CI=true + -e + E2E_IN_DOCKER=true + --mount=type=bind,source=${project.basedir},target=/app + -v + webforj-tutorial-e2e-maven-repo:/var/maven/.m2 + -e + MAVEN_CONFIG=/var/maven/.m2 + ${docker.image} + mvn + -ntp + -Dmaven.repo.local=/var/maven/.m2/repository + -Ddocker.e2e.container=true + -DupdateScreenshots=${updateScreenshots} + -Dit.test=${it.test} + verify + + + + + docker-e2e-single + + exec + + + docker + + run + --rm + --init + --ipc=host + -e + CI=true + -e + E2E_IN_DOCKER=true + --mount=type=bind,source=${project.basedir},target=/app + -v + webforj-tutorial-e2e-maven-repo:/var/maven/.m2 + -e + MAVEN_CONFIG=/var/maven/.m2 + ${docker.image} + mvn + -ntp + -Dmaven.repo.local=/var/maven/.m2/repository + -Ddocker.e2e.container=true + -DupdateScreenshots=false + -Dit.test=${test} + verify + + + + + docker-e2e-update + + exec + + + docker + + run + --rm + --init + --ipc=host + -e + CI=true + -e + E2E_IN_DOCKER=true + --mount=type=bind,source=${project.basedir},target=/app + -v + webforj-tutorial-e2e-maven-repo:/var/maven/.m2 + -e + MAVEN_CONFIG=/var/maven/.m2 + ${docker.image} + mvn + -ntp + -Dmaven.repo.local=/var/maven/.m2/repository + -Ddocker.e2e.container=true + -DupdateScreenshots=true + -Dit.test=${it.test} + verify + + + + + + + + + + + + container-e2e + + + docker.e2e.container + true + + + + + com.microsoft.playwright + playwright + ${playwright.version} + test + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + add-e2e-test-source + generate-test-sources + add-test-source + + + ${project.basedir}/e2e/src/test/java + + + + + add-e2e-test-resources + generate-test-resources + add-test-resource + + + + ${project.basedir}/e2e/src/test/resources + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + build-step-1 + pre-integration-test + exec + + mvn + -ntp-f${project.basedir}/1-creating-a-basic-app/pom.xml-DskipTestspackage + + + + build-step-2 + pre-integration-test + exec + + mvn + -ntp-f${project.basedir}/2-working-with-data/pom.xml-DskipTestspackage + + + + build-step-3 + pre-integration-test + exec + + mvn + -ntp-f${project.basedir}/3-routing-and-composites/pom.xml-DskipTestspackage + + + + build-step-4 + pre-integration-test + exec + + mvn + -ntp-f${project.basedir}/4-observers-and-route-parameters/pom.xml-DskipTestspackage + + + + build-step-5 + pre-integration-test + exec + + mvn + -ntp-f${project.basedir}/5-validating-and-binding-data/pom.xml-DskipTestspackage + + + + build-step-6 + pre-integration-test + exec + + mvn + -ntp-f${project.basedir}/6-integrating-an-app-layout/pom.xml-DskipTestspackage + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + + integration-test + verify + + + + + 1 + true + false + false + + ${project.basedir}/e2e/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + + + + + + + + From 5e7df6b6decabe03d39a3819702215a1f1a540bd Mon Sep 17 00:00:00 2001 From: Eric Handtke Date: Fri, 4 Sep 2026 12:49:55 +0200 Subject: [PATCH 4/9] remove unnecessary pom and application.properties configs --- 1-creating-a-basic-app/pom.xml | 33 ------------------- .../com/webforj/tutorial/Application.java | 2 +- .../src/main/resources/application.properties | 30 +++-------------- 2-working-with-data/pom.xml | 33 ------------------- .../src/main/resources/application.properties | 26 ++++----------- 3-routing-and-composites/pom.xml | 33 ------------------- .../src/main/resources/application.properties | 26 ++++----------- 4-observers-and-route-parameters/pom.xml | 33 ------------------- .../src/main/resources/application.properties | 26 ++++----------- 5-validating-and-binding-data/pom.xml | 29 ---------------- .../src/main/resources/application.properties | 26 ++++----------- 6-integrating-an-app-layout/pom.xml | 29 ---------------- .../src/main/resources/application.properties | 26 ++++----------- 13 files changed, 36 insertions(+), 316 deletions(-) diff --git a/1-creating-a-basic-app/pom.xml b/1-creating-a-basic-app/pom.xml index 44abbbf..b53f0ca 100644 --- a/1-creating-a-basic-app/pom.xml +++ b/1-creating-a-basic-app/pom.xml @@ -15,9 +15,6 @@ 21 26.02 - 21 - 21 - UTF-8 @@ -35,21 +32,6 @@ com.webforj webforj-spring-boot-starter - - org.springframework.boot - spring-boot-starter-validation - - - org.springframework.boot - spring-boot-starter-test - test - - - com.vaadin.external.google - android-json - - - compile webforj:watch spring-boot:run @@ -68,22 +50,7 @@ org.springframework.boot spring-boot-maven-plugin - - true - - - - prod - - - - dev - - true - - - diff --git a/1-creating-a-basic-app/src/main/java/com/webforj/tutorial/Application.java b/1-creating-a-basic-app/src/main/java/com/webforj/tutorial/Application.java index dbba917..42f9b73 100644 --- a/1-creating-a-basic-app/src/main/java/com/webforj/tutorial/Application.java +++ b/1-creating-a-basic-app/src/main/java/com/webforj/tutorial/Application.java @@ -27,7 +27,7 @@ public static void main(String[] args) { public void run() throws WebforjException { Frame mainFrame = new Frame(); Paragraph tutorial = new Paragraph("Tutorial App!"); - Button btn = new Button("Info"); + Button btn = new Button("hotswap an"); btn .setTheme(ButtonTheme.PRIMARY) diff --git a/1-creating-a-basic-app/src/main/resources/application.properties b/1-creating-a-basic-app/src/main/resources/application.properties index 9ed58e8..d53690b 100644 --- a/1-creating-a-basic-app/src/main/resources/application.properties +++ b/1-creating-a-basic-app/src/main/resources/application.properties @@ -1,28 +1,8 @@ -# Spring Boot configuration -spring.application.name=CustomerApplication - -# Server configuration -server.shutdown=immediate -server.port=8080 - -# webforJ DevTools configuration -webforj.devtools.livereload.enabled=true -webforj.devtools.livereload.static-resources-enabled=true -webforj.devtools.browser.open=true +# webforJ configuration +webforj.entry=com.webforj.tutorial.Application webforj.debug=true webforj.devtools.craftforj.enabled=true -webforj.entry = com.webforj.tutorial.Application - -# H2 Database configuration -# spring.datasource.url=jdbc:h2:mem:testdb -# spring.datasource.driverClassName=org.h2.Driver -# spring.datasource.username=sa -# spring.datasource.password= - -# JPA configuration -# spring.jpa.database-platform=org.hibernate.dialect.H2Dialect -# spring.jpa.hibernate.ddl-auto=update +webforj.devtools.livereload.enabled=true -# H2 Console (optional - for development) -# spring.h2.console.enabled=true -# spring.h2.console.path=/h2-console +# Hot reload configuration +server.shutdown=immediate diff --git a/2-working-with-data/pom.xml b/2-working-with-data/pom.xml index 59e7357..051ed83 100644 --- a/2-working-with-data/pom.xml +++ b/2-working-with-data/pom.xml @@ -15,9 +15,6 @@ 21 26.02 - 21 - 21 - UTF-8 @@ -39,21 +36,6 @@ org.springframework.boot spring-boot-starter-data-jpa - - org.springframework.boot - spring-boot-starter-validation - - - org.springframework.boot - spring-boot-starter-test - test - - - com.vaadin.external.google - android-json - - - com.h2database h2 @@ -76,22 +58,7 @@ org.springframework.boot spring-boot-maven-plugin - - true - - - - prod - - - - dev - - true - - - diff --git a/2-working-with-data/src/main/resources/application.properties b/2-working-with-data/src/main/resources/application.properties index 872c0a9..6990c46 100644 --- a/2-working-with-data/src/main/resources/application.properties +++ b/2-working-with-data/src/main/resources/application.properties @@ -1,28 +1,14 @@ -# Spring Boot configuration -spring.application.name=CustomerApplication - -# Server configuration -server.shutdown=immediate -server.port=8080 - -# webforJ DevTools configuration -webforj.devtools.livereload.enabled=true -webforj.devtools.livereload.static-resources-enabled=true -webforj.devtools.browser.open=true +# webforJ configuration +webforj.entry=com.webforj.tutorial.Application webforj.debug=true webforj.devtools.craftforj.enabled=true -webforj.entry = com.webforj.tutorial.Application +webforj.devtools.livereload.enabled=true + +# Hot reload configuration +server.shutdown=immediate # H2 Database configuration spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= # JPA configuration -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update - -# H2 Console (optional - for development) -# spring.h2.console.enabled=true -# spring.h2.console.path=/h2-console diff --git a/3-routing-and-composites/pom.xml b/3-routing-and-composites/pom.xml index 59e7357..051ed83 100644 --- a/3-routing-and-composites/pom.xml +++ b/3-routing-and-composites/pom.xml @@ -15,9 +15,6 @@ 21 26.02 - 21 - 21 - UTF-8 @@ -39,21 +36,6 @@ org.springframework.boot spring-boot-starter-data-jpa - - org.springframework.boot - spring-boot-starter-validation - - - org.springframework.boot - spring-boot-starter-test - test - - - com.vaadin.external.google - android-json - - - com.h2database h2 @@ -76,22 +58,7 @@ org.springframework.boot spring-boot-maven-plugin - - true - - - - prod - - - - dev - - true - - - diff --git a/3-routing-and-composites/src/main/resources/application.properties b/3-routing-and-composites/src/main/resources/application.properties index 18be8f3..25cdef0 100644 --- a/3-routing-and-composites/src/main/resources/application.properties +++ b/3-routing-and-composites/src/main/resources/application.properties @@ -1,29 +1,15 @@ -# Spring Boot configuration -spring.application.name=CustomerApplication - -# Server configuration -server.shutdown=immediate -server.port=8080 - -# webforJ DevTools configuration -webforj.devtools.livereload.enabled=true -webforj.devtools.livereload.static-resources-enabled=true -webforj.devtools.browser.open=true +# webforJ configuration +webforj.entry=com.webforj.tutorial.Application webforj.debug=true webforj.devtools.craftforj.enabled=true -webforj.entry = com.webforj.tutorial.Application +webforj.devtools.livereload.enabled=true + +# Hot reload configuration +server.shutdown=immediate # H2 Database configuration spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= # JPA configuration -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update -# H2 Console (optional - for development) -spring.h2.console.enabled=true -spring.h2.console.path=/h2-console - diff --git a/4-observers-and-route-parameters/pom.xml b/4-observers-and-route-parameters/pom.xml index 59e7357..051ed83 100644 --- a/4-observers-and-route-parameters/pom.xml +++ b/4-observers-and-route-parameters/pom.xml @@ -15,9 +15,6 @@ 21 26.02 - 21 - 21 - UTF-8 @@ -39,21 +36,6 @@ org.springframework.boot spring-boot-starter-data-jpa - - org.springframework.boot - spring-boot-starter-validation - - - org.springframework.boot - spring-boot-starter-test - test - - - com.vaadin.external.google - android-json - - - com.h2database h2 @@ -76,22 +58,7 @@ org.springframework.boot spring-boot-maven-plugin - - true - - - - prod - - - - dev - - true - - - diff --git a/4-observers-and-route-parameters/src/main/resources/application.properties b/4-observers-and-route-parameters/src/main/resources/application.properties index 18be8f3..25cdef0 100644 --- a/4-observers-and-route-parameters/src/main/resources/application.properties +++ b/4-observers-and-route-parameters/src/main/resources/application.properties @@ -1,29 +1,15 @@ -# Spring Boot configuration -spring.application.name=CustomerApplication - -# Server configuration -server.shutdown=immediate -server.port=8080 - -# webforJ DevTools configuration -webforj.devtools.livereload.enabled=true -webforj.devtools.livereload.static-resources-enabled=true -webforj.devtools.browser.open=true +# webforJ configuration +webforj.entry=com.webforj.tutorial.Application webforj.debug=true webforj.devtools.craftforj.enabled=true -webforj.entry = com.webforj.tutorial.Application +webforj.devtools.livereload.enabled=true + +# Hot reload configuration +server.shutdown=immediate # H2 Database configuration spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= # JPA configuration -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update -# H2 Console (optional - for development) -spring.h2.console.enabled=true -spring.h2.console.path=/h2-console - diff --git a/5-validating-and-binding-data/pom.xml b/5-validating-and-binding-data/pom.xml index 59e7357..405f970 100644 --- a/5-validating-and-binding-data/pom.xml +++ b/5-validating-and-binding-data/pom.xml @@ -15,9 +15,6 @@ 21 26.02 - 21 - 21 - UTF-8 @@ -43,17 +40,6 @@ org.springframework.boot spring-boot-starter-validation - - org.springframework.boot - spring-boot-starter-test - test - - - com.vaadin.external.google - android-json - - - com.h2database h2 @@ -76,22 +62,7 @@ org.springframework.boot spring-boot-maven-plugin - - true - - - - prod - - - - dev - - true - - - diff --git a/5-validating-and-binding-data/src/main/resources/application.properties b/5-validating-and-binding-data/src/main/resources/application.properties index 18be8f3..25cdef0 100644 --- a/5-validating-and-binding-data/src/main/resources/application.properties +++ b/5-validating-and-binding-data/src/main/resources/application.properties @@ -1,29 +1,15 @@ -# Spring Boot configuration -spring.application.name=CustomerApplication - -# Server configuration -server.shutdown=immediate -server.port=8080 - -# webforJ DevTools configuration -webforj.devtools.livereload.enabled=true -webforj.devtools.livereload.static-resources-enabled=true -webforj.devtools.browser.open=true +# webforJ configuration +webforj.entry=com.webforj.tutorial.Application webforj.debug=true webforj.devtools.craftforj.enabled=true -webforj.entry = com.webforj.tutorial.Application +webforj.devtools.livereload.enabled=true + +# Hot reload configuration +server.shutdown=immediate # H2 Database configuration spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= # JPA configuration -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update -# H2 Console (optional - for development) -spring.h2.console.enabled=true -spring.h2.console.path=/h2-console - diff --git a/6-integrating-an-app-layout/pom.xml b/6-integrating-an-app-layout/pom.xml index 59e7357..405f970 100644 --- a/6-integrating-an-app-layout/pom.xml +++ b/6-integrating-an-app-layout/pom.xml @@ -15,9 +15,6 @@ 21 26.02 - 21 - 21 - UTF-8 @@ -43,17 +40,6 @@ org.springframework.boot spring-boot-starter-validation - - org.springframework.boot - spring-boot-starter-test - test - - - com.vaadin.external.google - android-json - - - com.h2database h2 @@ -76,22 +62,7 @@ org.springframework.boot spring-boot-maven-plugin - - true - - - - prod - - - - dev - - true - - - diff --git a/6-integrating-an-app-layout/src/main/resources/application.properties b/6-integrating-an-app-layout/src/main/resources/application.properties index 18be8f3..25cdef0 100644 --- a/6-integrating-an-app-layout/src/main/resources/application.properties +++ b/6-integrating-an-app-layout/src/main/resources/application.properties @@ -1,29 +1,15 @@ -# Spring Boot configuration -spring.application.name=CustomerApplication - -# Server configuration -server.shutdown=immediate -server.port=8080 - -# webforJ DevTools configuration -webforj.devtools.livereload.enabled=true -webforj.devtools.livereload.static-resources-enabled=true -webforj.devtools.browser.open=true +# webforJ configuration +webforj.entry=com.webforj.tutorial.Application webforj.debug=true webforj.devtools.craftforj.enabled=true -webforj.entry = com.webforj.tutorial.Application +webforj.devtools.livereload.enabled=true + +# Hot reload configuration +server.shutdown=immediate # H2 Database configuration spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= # JPA configuration -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update -# H2 Console (optional - for development) -spring.h2.console.enabled=true -spring.h2.console.path=/h2-console - From d0edf2298610add8576e4ff2068ef90397028ede Mon Sep 17 00:00:00 2001 From: Eric Handtke Date: Fri, 4 Sep 2026 14:06:51 +0200 Subject: [PATCH 5/9] Overhaul tests for fixed timings --- .../com/webforj/tutorial/Application.java | 2 +- .../com/webforj/tutorial/e2e/BaseTest.java | 46 ++++++++++++------- .../com/webforj/tutorial/e2e/Step2IT.java | 38 ++++++--------- .../com/webforj/tutorial/e2e/Step3IT.java | 18 ++++---- .../com/webforj/tutorial/e2e/Step4IT.java | 22 +++------ .../com/webforj/tutorial/e2e/Step5IT.java | 3 +- .../com/webforj/tutorial/e2e/Step6IT.java | 32 ++++--------- 7 files changed, 72 insertions(+), 89 deletions(-) diff --git a/1-creating-a-basic-app/src/main/java/com/webforj/tutorial/Application.java b/1-creating-a-basic-app/src/main/java/com/webforj/tutorial/Application.java index 42f9b73..dbba917 100644 --- a/1-creating-a-basic-app/src/main/java/com/webforj/tutorial/Application.java +++ b/1-creating-a-basic-app/src/main/java/com/webforj/tutorial/Application.java @@ -27,7 +27,7 @@ public static void main(String[] args) { public void run() throws WebforjException { Frame mainFrame = new Frame(); Paragraph tutorial = new Paragraph("Tutorial App!"); - Button btn = new Button("hotswap an"); + Button btn = new Button("Info"); btn .setTheme(ButtonTheme.PRIMARY) diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/BaseTest.java b/e2e/src/test/java/com/webforj/tutorial/e2e/BaseTest.java index 1807a58..79469ba 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/BaseTest.java +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/BaseTest.java @@ -13,6 +13,7 @@ import com.microsoft.playwright.Request; import com.microsoft.playwright.Tracing; import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.AriaRole; import com.microsoft.playwright.options.ColorScheme; import com.microsoft.playwright.options.WaitUntilState; import java.io.IOException; @@ -40,16 +41,16 @@ abstract class BaseTest { protected abstract String stepDirectory(); @BeforeAll - void startApplicationAndBrowser() throws Exception { - application = TutorialApp.start(stepDirectory()); + void startBrowser() { playwright = Playwright.create(); browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); PlaywrightAssertions.setDefaultAssertionTimeout(10_000); } @BeforeEach - void createBrowserContext() { + void startApplicationAndCreateBrowserContext() throws Exception { browserErrors.clear(); + application = TutorialApp.start(stepDirectory()); context = browser.newContext(new Browser.NewContextOptions() .setViewportSize(1440, 900) .setDeviceScaleFactor(1) @@ -71,31 +72,35 @@ void createBrowserContext() { @AfterEach void closeBrowserContext(TestInfo testInfo) throws IOException { List errorsBeforeClose = List.copyOf(browserErrors); - if (context != null) { - Path traceDirectory = Path.of("target", "playwright-traces"); - Files.createDirectories(traceDirectory); - String testName = (getClass().getSimpleName() + "-" + testInfo.getDisplayName()) - .replaceAll("[^a-zA-Z0-9.-]", "-"); - context.tracing().stop(new Tracing.StopOptions() - .setPath(traceDirectory.resolve(testName + ".zip"))); - context.close(); - context = null; + try { + if (context != null) { + Path traceDirectory = Path.of("target", "playwright-traces"); + Files.createDirectories(traceDirectory); + String testName = (getClass().getSimpleName() + "-" + testInfo.getDisplayName()) + .replaceAll("[^a-zA-Z0-9.-]", "-"); + context.tracing().stop(new Tracing.StopOptions() + .setPath(traceDirectory.resolve(testName + ".zip"))); + context.close(); + context = null; + } + } finally { + if (application != null) { + application.close(); + application = null; + } } assertTrue(errorsBeforeClose.isEmpty(), () -> "Browser runtime/network errors:\n" + String.join("\n", errorsBeforeClose)); } @AfterAll - void stopApplicationAndBrowser() { + void stopBrowser() { if (browser != null) { browser.close(); } if (playwright != null) { playwright.close(); } - if (application != null) { - application.close(); - } } protected void openApplication() { @@ -124,6 +129,15 @@ protected Locator expectCustomerTable() { return table; } + protected Locator expectCustomerRow(String identifyingCellText) { + Locator identifyingCell = page.getByRole(AriaRole.CELL, + new Page.GetByRoleOptions().setName(identifyingCellText).setExact(true)); + Locator row = page.getByRole(AriaRole.ROW) + .filter(new Locator.FilterOptions().setHas(identifyingCell)); + assertThat(row).hasCount(1); + return row; + } + private void recordConsoleError(ConsoleMessage message) { if ("error".equals(message.type())) { browserErrors.add("console: " + message.text()); diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step2IT.java b/e2e/src/test/java/com/webforj/tutorial/e2e/Step2IT.java index cc8b68c..d6702a9 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/Step2IT.java +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/Step2IT.java @@ -1,12 +1,10 @@ package com.webforj.tutorial.e2e; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; -import static org.junit.jupiter.api.Assertions.fail; import com.microsoft.playwright.Locator; import com.microsoft.playwright.Page; import com.microsoft.playwright.options.AriaRole; -import com.microsoft.playwright.options.BoundingBox; import java.io.IOException; import org.junit.jupiter.api.Test; @@ -21,9 +19,8 @@ void rendersSeededDataDialogAndSorting() throws IOException { openApplication(); assertThat(page.getByText("Tutorial App!", new Page.GetByTextOptions().setExact(true))).isVisible(); - Locator table = expectCustomerTable(); - assertThat(table).containsText("John"); - assertThat(table).containsText("Innovatech"); + expectCustomerTable(); + assertThat(expectCustomerRow("John")).containsText("Innovatech"); page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Info").setExact(true)).click(); @@ -34,26 +31,19 @@ void rendersSeededDataDialogAndSorting() throws IOException { Locator firstNameHeader = page.locator( "dwc-table [part~='cell-header'][data-column='firstName']"); - firstNameHeader.click(); - waitForRowOrder("Emma", "John"); - firstNameHeader.click(); - waitForRowOrder("John", "Emma"); - } + Locator firstNameCells = page.locator( + "dwc-table [part~='cell'][data-column='firstName']:not([part~='cell-header'])"); - private void waitForRowOrder(String first, String second) { - long deadline = System.nanoTime() + 10_000_000_000L; - while (System.nanoTime() < deadline) { - if (rowTop(first) < rowTop(second)) { - return; - } - page.waitForTimeout(100); - } - fail("Expected row " + first + " to appear above " + second); - } + firstNameHeader.click(); + assertThat(firstNameCells).hasText(new String[] { + "Alice", "Emma", "Isabella", "James", "John", + "Liam", "Lucas", "Noah", "Olivia", "Sophia" + }); - private double rowTop(String name) { - Locator cell = page.getByText(name, new Page.GetByTextOptions().setExact(true)); - BoundingBox box = cell.boundingBox(); - return box == null ? Double.MAX_VALUE : box.y; + firstNameHeader.click(); + assertThat(firstNameCells).hasText(new String[] { + "Sophia", "Olivia", "Noah", "Lucas", "Liam", + "John", "James", "Isabella", "Emma", "Alice" + }); } } diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step3IT.java b/e2e/src/test/java/com/webforj/tutorial/e2e/Step3IT.java index 69e92d9..48d1334 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/Step3IT.java +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/Step3IT.java @@ -2,6 +2,8 @@ import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; import com.microsoft.playwright.options.AriaRole; import java.io.IOException; import java.util.regex.Pattern; @@ -18,13 +20,13 @@ void createsCustomerAndReturnsToTable() throws IOException { openApplication(); expectCustomerTable(); - page.getByRole(AriaRole.BUTTON, new com.microsoft.playwright.Page.GetByRoleOptions() + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions() .setName("Add Customer")).click(); assertThat(page).hasURL(Pattern.compile("/customer$")); assertThat(page.getByRole(AriaRole.BUTTON, - new com.microsoft.playwright.Page.GetByRoleOptions().setName("Submit"))).isVisible(); + new Page.GetByRoleOptions().setName("Submit"))).isVisible(); assertThat(page.getByRole(AriaRole.BUTTON, - new com.microsoft.playwright.Page.GetByRoleOptions().setName("Cancel"))).isVisible(); + new Page.GetByRoleOptions().setName("Cancel"))).isVisible(); page.getByLabel("First Name").fill("Ada"); page.getByLabel("Last Name").fill("Lovelace"); @@ -32,15 +34,15 @@ void createsCustomerAndReturnsToTable() throws IOException { VisualAssertions.assertScreenshot(page, "completed-customer-form.png"); page.getByRole(AriaRole.BUTTON, - new com.microsoft.playwright.Page.GetByRoleOptions().setName("Submit")).click(); + new Page.GetByRoleOptions().setName("Submit")).click(); assertThat(page).hasURL(Pattern.compile("/$")); - assertThat(customerTable()).containsText("Ada"); - assertThat(customerTable()).containsText("Analytical Engines"); + Locator adaRow = expectCustomerRow("Ada"); + assertThat(adaRow).containsText("Analytical Engines"); page.getByRole(AriaRole.BUTTON, - new com.microsoft.playwright.Page.GetByRoleOptions().setName("Add Customer")).click(); + new Page.GetByRoleOptions().setName("Add Customer")).click(); page.getByRole(AriaRole.BUTTON, - new com.microsoft.playwright.Page.GetByRoleOptions().setName("Cancel")).click(); + new Page.GetByRoleOptions().setName("Cancel")).click(); assertThat(page).hasURL(Pattern.compile("/$")); } } diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step4IT.java b/e2e/src/test/java/com/webforj/tutorial/e2e/Step4IT.java index ccbd935..43ee235 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/Step4IT.java +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/Step4IT.java @@ -6,12 +6,8 @@ import com.microsoft.playwright.options.AriaRole; import java.io.IOException; import java.util.regex.Pattern; -import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; -import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestMethodOrder; -@TestMethodOrder(OrderAnnotation.class) class Step4IT extends BaseTest { @Override protected String stepDirectory() { @@ -19,7 +15,6 @@ protected String stepDirectory() { } @Test - @Order(1) void showsRoutedCustomerFormConsistently() throws IOException { openApplication("/customer/1"); assertThat(page.getByLabel("First Name")).hasValue("Alice"); @@ -29,26 +24,23 @@ void showsRoutedCustomerFormConsistently() throws IOException { } @Test - @Order(2) void loadsCustomerFromRouteAndSavesEdits() { openApplication(); expectCustomerTable(); - page.getByText("Alice", new Page.GetByTextOptions().setExact(true)).click(); - assertThat(page).hasURL(Pattern.compile("/customer/1$")); - assertThat(page.getByLabel("First Name")).hasValue("Alice"); - assertThat(page.getByLabel("Last Name")).hasValue("Smith"); + page.getByText("John", new Page.GetByTextOptions().setExact(true)).click(); + assertThat(page).hasURL(Pattern.compile("/customer/2$")); + assertThat(page.getByLabel("First Name")).hasValue("John"); + assertThat(page.getByLabel("Last Name")).hasValue("Doe"); var company = page.getByLabel("Company"); company.click(); company.press("Control+A"); - company.pressSequentially("Updated TechCorp", new com.microsoft.playwright.Locator.PressSequentiallyOptions() - .setDelay(25)); - assertThat(company).hasValue("Updated TechCorp"); + company.pressSequentially("Updated Innovatech"); + assertThat(company).hasValue("Updated Innovatech"); page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit")).click(); assertThat(page).hasURL(Pattern.compile("/$")); - assertThat(page.getByRole(AriaRole.CELL, - new Page.GetByRoleOptions().setName("Updated TechCorp").setExact(true))).isVisible(); + assertThat(expectCustomerRow("John")).containsText("Updated Innovatech"); openApplication("/customer/999999"); assertThat(page).hasURL(Pattern.compile("/$")); diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step5IT.java b/e2e/src/test/java/com/webforj/tutorial/e2e/Step5IT.java index 3cc9f1a..9f3c6a6 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/Step5IT.java +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/Step5IT.java @@ -37,8 +37,7 @@ void bindsAndValidatesCustomerBeforeSubmission() throws IOException { submit.click(); assertThat(page).hasURL(Pattern.compile("/$")); - assertThat(customerTable()).containsText("Grace"); - assertThat(customerTable()).containsText("Compiler Systems"); + assertThat(expectCustomerRow("Grace")).containsText("Compiler Systems"); } @Test diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step6IT.java b/e2e/src/test/java/com/webforj/tutorial/e2e/Step6IT.java index c57d3b1..cb07b80 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/Step6IT.java +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/Step6IT.java @@ -1,8 +1,6 @@ package com.webforj.tutorial.e2e; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; import com.microsoft.playwright.Locator; import com.microsoft.playwright.Page; @@ -48,38 +46,26 @@ void providesApplicationLayoutNavigation() throws IOException { void keepsApplicationLayoutUsableAtNarrowViewport() throws IOException { page.setViewportSize(390, 844); openApplication(); + expectCustomerTable(); assertThat(page.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions().setName("Customer Table").setLevel(1))).isVisible(); assertThat(page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Add Customer"))).isVisible(); - VisualAssertions.assertScreenshot(page, "dashboard-mobile.png"); Locator dashboardLink = page.getByRole(AriaRole.LINK, new Page.GetByRoleOptions().setName(Pattern.compile("Dashboard"))); - waitForViewportState(dashboardLink, false); - assertFalse(isInViewport(dashboardLink), "Dashboard link should start outside the mobile viewport"); + assertThat(dashboardLink).not().isInViewport(); + waitForLayoutToSettle(); + VisualAssertions.assertScreenshot(page, "dashboard-mobile.png"); + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName(Pattern.compile("menu", Pattern.CASE_INSENSITIVE))).click(); - waitForViewportState(dashboardLink, true); - assertTrue(isInViewport(dashboardLink), "Dashboard link should enter the viewport when the drawer opens"); - } - - private void waitForViewportState(Locator locator, boolean expected) { - long deadline = System.nanoTime() + 10_000_000_000L; - while (System.nanoTime() < deadline) { - if (isInViewport(locator) == expected) { - return; - } - page.waitForTimeout(100); - } + assertThat(dashboardLink).isInViewport(); } - private boolean isInViewport(Locator locator) { - return Boolean.TRUE.equals(locator.evaluate("element => {" - + "const rect = element.getBoundingClientRect();" - + "return rect.bottom > 0 && rect.right > 0 " - + "&& rect.top < window.innerHeight && rect.left < window.innerWidth;" - + "}")); + private void waitForLayoutToSettle() { + page.evaluate("() => new Promise(resolve => requestAnimationFrame(() => " + + "requestAnimationFrame(() => resolve())))"); } } From 5845dc0ba9b7b6bd9369507f807e42ca3b744f13 Mon Sep 17 00:00:00 2001 From: Eric Handtke Date: Fri, 4 Sep 2026 14:17:15 +0200 Subject: [PATCH 6/9] add cronjob for github actions --- .github/workflows/e2e.yml | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..76faec9 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,45 @@ +name: End-to-End Tests + +on: + schedule: + # 07:00 UTC = 08:00 CET (Europe/Berlin winter time). GitHub cron is UTC only, + # so this fires at 09:00 local during CEST (summer time). + - cron: '0 7 * * *' + workflow_dispatch: + inputs: + it_test: + description: 'Test filter passed to -Dit.test (e.g. Step3IT)' + required: false + default: '*IT' + +jobs: + e2e: + name: Playwright suite (Docker) + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + + - name: Run end-to-end test suite + run: mvn -ntp -B verify -Dit.test='${{ inputs.it_test || '*IT' }}' + + - name: Upload failure artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-artifacts + path: | + target/visual-diffs + target/playwright-traces + target/e2e-artifacts + if-no-files-found: ignore + retention-days: 14 From e23fbff1e2d6aafc85a7d0a7c38f553893d9cb75 Mon Sep 17 00:00:00 2001 From: Eric Handtke Date: Fri, 4 Sep 2026 16:01:28 +0200 Subject: [PATCH 7/9] address screenshot flakiness --- .../com/webforj/tutorial/e2e/Step6IT.java | 6 - .../tutorial/e2e/VisualAssertions.java | 133 +++++++++++++++--- 2 files changed, 111 insertions(+), 28 deletions(-) diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step6IT.java b/e2e/src/test/java/com/webforj/tutorial/e2e/Step6IT.java index cb07b80..8fac7f9 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/Step6IT.java +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/Step6IT.java @@ -56,16 +56,10 @@ void keepsApplicationLayoutUsableAtNarrowViewport() throws IOException { Locator dashboardLink = page.getByRole(AriaRole.LINK, new Page.GetByRoleOptions().setName(Pattern.compile("Dashboard"))); assertThat(dashboardLink).not().isInViewport(); - waitForLayoutToSettle(); VisualAssertions.assertScreenshot(page, "dashboard-mobile.png"); page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName(Pattern.compile("menu", Pattern.CASE_INSENSITIVE))).click(); assertThat(dashboardLink).isInViewport(); } - - private void waitForLayoutToSettle() { - page.evaluate("() => new Promise(resolve => requestAnimationFrame(() => " - + "requestAnimationFrame(() => resolve())))"); - } } diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/VisualAssertions.java b/e2e/src/test/java/com/webforj/tutorial/e2e/VisualAssertions.java index f2383e8..4d53ced 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/VisualAssertions.java +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/VisualAssertions.java @@ -12,23 +12,44 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Arrays; import javax.imageio.ImageIO; final class VisualAssertions { private static final int DEFAULT_MAX_DIFFERENT_PIXELS = 500; private static final int CHANNEL_THRESHOLD = 51; + private static final int DEFAULT_TIMEOUT_MS = 10_000; + private static final int POLL_INTERVAL_MS = 250; + + /** + * Every {@code dwc-icon} resolves its artwork over the network and injects an {@code } into + * its own shadow root, so an unresolved icon is invisible in a screenshot while the surrounding + * DOM already looks complete. Icons live inside nested shadow roots, hence the manual walk. + */ + private static final String ICONS_RENDERED = """ + () => { + const icons = []; + const walk = (root) => { + for (const element of root.querySelectorAll('*')) { + if (element.localName === 'dwc-icon') { + icons.push(element); + } + if (element.shadowRoot) { + walk(element.shadowRoot); + } + } + }; + walk(document); + return icons.every(icon => icon.shadowRoot && icon.shadowRoot.querySelector('svg')); + } + """; private VisualAssertions() { } static void assertScreenshot(Page page, String name) throws IOException { requirePngName(name); - page.evaluate("() => document.fonts.ready"); - - byte[] screenshot = page.screenshot(new Page.ScreenshotOptions() - .setAnimations(ScreenshotAnimations.DISABLED) - .setCaret(ScreenshotCaret.HIDE) - .setScale(ScreenshotScale.CSS)); + awaitRenderedIcons(page); Path baselineDirectory = Path.of(requiredProperty("visual.baseline.dir")); Path artifactDirectory = Path.of(requiredProperty("visual.artifact.dir")); @@ -37,7 +58,7 @@ static void assertScreenshot(Page page, String name) throws IOException { if (Boolean.getBoolean("updateScreenshots")) { requireDocker(); Files.createDirectories(baselineDirectory); - Files.write(baseline, screenshot); + Files.write(baseline, captureStable(page)); return; } @@ -47,19 +68,89 @@ static void assertScreenshot(Page page, String name) throws IOException { } BufferedImage expected = ImageIO.read(baseline.toFile()); - BufferedImage actual = ImageIO.read(new ByteArrayInputStream(screenshot)); - if (expected == null || actual == null) { - fail("Could not decode screenshot baseline or actual image for " + name); + if (expected == null) { + fail("Could not decode screenshot baseline for " + name); } - if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { - writeFailureArtifacts(artifactDirectory, name, screenshot, null); - fail("Screenshot dimensions differ for " + name + ": expected " - + expected.getWidth() + "x" + expected.getHeight() + ", actual " - + actual.getWidth() + "x" + actual.getHeight()); + // Icons are fetched over the network and components paint asynchronously, so a single capture + // can catch a half-rendered page. Re-capture until the page matches the baseline or the + // deadline expires, and report the last mismatch. + int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); + long deadline = System.currentTimeMillis() + timeoutMs(); + byte[] screenshot; + BufferedImage actual; + int differentPixels; + BufferedImage diff; + + while (true) { + screenshot = capture(page); + actual = ImageIO.read(new ByteArrayInputStream(screenshot)); + if (actual == null) { + fail("Could not decode captured screenshot for " + name); + } + + if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { + writeFailureArtifacts(artifactDirectory, name, screenshot, null); + fail("Screenshot dimensions differ for " + name + ": expected " + + expected.getWidth() + "x" + expected.getHeight() + ", actual " + + actual.getWidth() + "x" + actual.getHeight()); + } + + diff = new BufferedImage(actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); + differentPixels = comparePixels(expected, actual, diff); + if (differentPixels <= maximum) { + return; + } + if (System.currentTimeMillis() >= deadline) { + break; + } + page.waitForTimeout(POLL_INTERVAL_MS); } - BufferedImage diff = new BufferedImage(actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); + writeFailureArtifacts(artifactDirectory, name, screenshot, diff); + fail("Screenshot mismatch for " + name + ": " + differentPixels + + " pixels differ (maximum " + maximum + ") after " + timeoutMs() + + " ms of retries. See " + artifactDirectory); + } + + private static void awaitRenderedIcons(Page page) { + try { + page.waitForFunction(ICONS_RENDERED, null, + new Page.WaitForFunctionOptions().setTimeout(timeoutMs())); + } catch (RuntimeException e) { + fail("Icons did not finish rendering within " + timeoutMs() + + " ms; screenshots would capture missing icons. Icon artwork is fetched from" + + " cdn.jsdelivr.net, so check network access from the container.", e); + } + } + + private static byte[] capture(Page page) { + page.evaluate("() => document.fonts.ready"); + return page.screenshot(new Page.ScreenshotOptions() + .setAnimations(ScreenshotAnimations.DISABLED) + .setCaret(ScreenshotCaret.HIDE) + .setScale(ScreenshotScale.CSS)); + } + + /** + * Captures once the page stops changing, so regenerated baselines never record a transient + * render. Falls back to the last capture if the page never settles. + */ + private static byte[] captureStable(Page page) { + long deadline = System.currentTimeMillis() + timeoutMs(); + byte[] previous = capture(page); + while (System.currentTimeMillis() < deadline) { + page.waitForTimeout(POLL_INTERVAL_MS); + byte[] current = capture(page); + if (Arrays.equals(previous, current)) { + return current; + } + previous = current; + } + return previous; + } + + private static int comparePixels(BufferedImage expected, BufferedImage actual, BufferedImage diff) { int differentPixels = 0; for (int y = 0; y < actual.getHeight(); y++) { for (int x = 0; x < actual.getWidth(); x++) { @@ -75,13 +166,11 @@ static void assertScreenshot(Page page, String name) throws IOException { } } } + return differentPixels; + } - int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); - if (differentPixels > maximum) { - writeFailureArtifacts(artifactDirectory, name, screenshot, diff); - fail("Screenshot mismatch for " + name + ": " + differentPixels - + " pixels differ (maximum " + maximum + "). See " + artifactDirectory); - } + private static int timeoutMs() { + return Integer.getInteger("visual.timeoutMs", DEFAULT_TIMEOUT_MS); } private static boolean isDifferent(int expectedRgb, int actualRgb) { From 1463806893296965880fd73d383aab9a619846ad Mon Sep 17 00:00:00 2001 From: Eric Handtke Date: Fri, 4 Sep 2026 16:28:31 +0200 Subject: [PATCH 8/9] fixed screenshot issues --- .../tutorial/e2e/VisualAssertions.java | 74 ++++++++----------- 1 file changed, 29 insertions(+), 45 deletions(-) diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/VisualAssertions.java b/e2e/src/test/java/com/webforj/tutorial/e2e/VisualAssertions.java index 4d53ced..2100277 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/VisualAssertions.java +++ b/e2e/src/test/java/com/webforj/tutorial/e2e/VisualAssertions.java @@ -54,11 +54,12 @@ static void assertScreenshot(Page page, String name) throws IOException { Path baselineDirectory = Path.of(requiredProperty("visual.baseline.dir")); Path artifactDirectory = Path.of(requiredProperty("visual.artifact.dir")); Path baseline = baselineDirectory.resolve(name); + byte[] screenshot = captureStable(page, name, artifactDirectory); if (Boolean.getBoolean("updateScreenshots")) { requireDocker(); Files.createDirectories(baselineDirectory); - Files.write(baseline, captureStable(page)); + Files.write(baseline, screenshot); return; } @@ -72,45 +73,27 @@ static void assertScreenshot(Page page, String name) throws IOException { fail("Could not decode screenshot baseline for " + name); } - // Icons are fetched over the network and components paint asynchronously, so a single capture - // can catch a half-rendered page. Re-capture until the page matches the baseline or the - // deadline expires, and report the last mismatch. - int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); - long deadline = System.currentTimeMillis() + timeoutMs(); - byte[] screenshot; - BufferedImage actual; - int differentPixels; - BufferedImage diff; - - while (true) { - screenshot = capture(page); - actual = ImageIO.read(new ByteArrayInputStream(screenshot)); - if (actual == null) { - fail("Could not decode captured screenshot for " + name); - } - - if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { - writeFailureArtifacts(artifactDirectory, name, screenshot, null); - fail("Screenshot dimensions differ for " + name + ": expected " - + expected.getWidth() + "x" + expected.getHeight() + ", actual " - + actual.getWidth() + "x" + actual.getHeight()); - } + BufferedImage actual = ImageIO.read(new ByteArrayInputStream(screenshot)); + if (actual == null) { + fail("Could not decode captured screenshot for " + name); + } - diff = new BufferedImage(actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); - differentPixels = comparePixels(expected, actual, diff); - if (differentPixels <= maximum) { - return; - } - if (System.currentTimeMillis() >= deadline) { - break; - } - page.waitForTimeout(POLL_INTERVAL_MS); + if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { + writeFailureArtifacts(artifactDirectory, name, screenshot, null); + fail("Screenshot dimensions differ for " + name + ": expected " + + expected.getWidth() + "x" + expected.getHeight() + ", actual " + + actual.getWidth() + "x" + actual.getHeight()); } - writeFailureArtifacts(artifactDirectory, name, screenshot, diff); - fail("Screenshot mismatch for " + name + ": " + differentPixels - + " pixels differ (maximum " + maximum + ") after " + timeoutMs() - + " ms of retries. See " + artifactDirectory); + BufferedImage diff = new BufferedImage( + actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); + int differentPixels = comparePixels(expected, actual, diff); + int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); + if (differentPixels > maximum) { + writeFailureArtifacts(artifactDirectory, name, screenshot, diff); + fail("Screenshot mismatch for " + name + ": " + differentPixels + + " pixels differ (maximum " + maximum + "). See " + artifactDirectory); + } } private static void awaitRenderedIcons(Page page) { @@ -132,14 +115,11 @@ private static byte[] capture(Page page) { .setScale(ScreenshotScale.CSS)); } - /** - * Captures once the page stops changing, so regenerated baselines never record a transient - * render. Falls back to the last capture if the page never settles. - */ - private static byte[] captureStable(Page page) { - long deadline = System.currentTimeMillis() + timeoutMs(); + private static byte[] captureStable( + Page page, String name, Path artifactDirectory) throws IOException { + long deadline = System.nanoTime() + timeoutMs() * 1_000_000L; byte[] previous = capture(page); - while (System.currentTimeMillis() < deadline) { + while (System.nanoTime() < deadline) { page.waitForTimeout(POLL_INTERVAL_MS); byte[] current = capture(page); if (Arrays.equals(previous, current)) { @@ -147,7 +127,11 @@ private static byte[] captureStable(Page page) { } previous = current; } - return previous; + + writeFailureArtifacts(artifactDirectory, name, previous, null); + return fail("Page did not produce two consecutive identical screenshots for " + name + + " within " + timeoutMs() + " ms. Refusing to compare or update an unstable image. See " + + artifactDirectory); } private static int comparePixels(BufferedImage expected, BufferedImage actual, BufferedImage diff) { From 323a0cb6350a477d715415a0b30aa955c23d2d5f Mon Sep 17 00:00:00 2001 From: Eric Handtke Date: Fri, 18 Sep 2026 14:58:57 +0200 Subject: [PATCH 9/9] separated tests and moved them to be in each step --- .dockerignore | 6 - .gitattributes | 1 + .github/workflows/e2e.yml | 27 +- 1-creating-a-basic-app/README.md | 19 ++ 1-creating-a-basic-app/pom.xml | 122 ++++++- .../java/com/webforj/tutorial}/BaseTest.java | 9 +- .../java/com/webforj/tutorial}/Step1IT.java | 7 +- .../com/webforj/tutorial}/TutorialApp.java | 44 +-- .../webforj/tutorial}/VisualAssertions.java | 2 +- .../screenshots/basic-application-dialog.png | Bin 2-working-with-data/README.md | 19 ++ 2-working-with-data/pom.xml | 126 ++++++- .../java/com/webforj/tutorial/BaseTest.java | 155 +++++++++ .../java/com/webforj/tutorial}/Step2IT.java | 7 +- .../com/webforj/tutorial/TutorialApp.java | 159 +++++++++ .../webforj/tutorial/VisualAssertions.java | 198 +++++++++++ .../resources/screenshots/customer-table.png | Bin 3-routing-and-composites/README.md | 19 ++ 3-routing-and-composites/pom.xml | 112 ++++++ .../java/com/webforj/tutorial/BaseTest.java | 155 +++++++++ .../java/com/webforj/tutorial}/Step3IT.java | 7 +- .../com/webforj/tutorial/TutorialApp.java | 159 +++++++++ .../webforj/tutorial/VisualAssertions.java | 198 +++++++++++ .../screenshots/completed-customer-form.png | Bin 4-observers-and-route-parameters/README.md | 19 ++ 4-observers-and-route-parameters/pom.xml | 112 ++++++ .../java/com/webforj/tutorial/BaseTest.java | 155 +++++++++ .../java/com/webforj/tutorial}/Step4IT.java | 7 +- .../com/webforj/tutorial/TutorialApp.java | 159 +++++++++ .../webforj/tutorial/VisualAssertions.java | 198 +++++++++++ .../screenshots/edit-customer-route.png | Bin 5-validating-and-binding-data/README.md | 19 ++ 5-validating-and-binding-data/pom.xml | 112 ++++++ .../java/com/webforj/tutorial/BaseTest.java | 155 +++++++++ .../java/com/webforj/tutorial}/Step5IT.java | 7 +- .../com/webforj/tutorial/TutorialApp.java | 159 +++++++++ .../webforj/tutorial/VisualAssertions.java | 198 +++++++++++ .../screenshots/valid-customer-form.png | Bin 6-integrating-an-app-layout/README.md | 19 ++ 6-integrating-an-app-layout/pom.xml | 112 ++++++ .../java/com/webforj/tutorial/BaseTest.java | 155 +++++++++ .../java/com/webforj/tutorial}/Step6IT.java | 7 +- .../com/webforj/tutorial/TutorialApp.java | 159 +++++++++ .../webforj/tutorial/VisualAssertions.java | 198 +++++++++++ .../screenshots/dashboard-layout.png | Bin .../screenshots/dashboard-mobile.png | Bin Dockerfile | 12 - README.md | 28 +- pom.xml | 323 ------------------ scripts/run-e2e.sh | 45 +++ 50 files changed, 3444 insertions(+), 465 deletions(-) delete mode 100644 .dockerignore create mode 100644 .gitattributes rename {e2e/src/test/java/com/webforj/tutorial/e2e => 1-creating-a-basic-app/src/test/java/com/webforj/tutorial}/BaseTest.java (96%) rename {e2e/src/test/java/com/webforj/tutorial/e2e => 1-creating-a-basic-app/src/test/java/com/webforj/tutorial}/Step1IT.java (88%) rename {e2e/src/test/java/com/webforj/tutorial/e2e => 1-creating-a-basic-app/src/test/java/com/webforj/tutorial}/TutorialApp.java (74%) rename {e2e/src/test/java/com/webforj/tutorial/e2e => 1-creating-a-basic-app/src/test/java/com/webforj/tutorial}/VisualAssertions.java (99%) rename {e2e => 1-creating-a-basic-app}/src/test/resources/screenshots/basic-application-dialog.png (100%) create mode 100644 2-working-with-data/src/test/java/com/webforj/tutorial/BaseTest.java rename {e2e/src/test/java/com/webforj/tutorial/e2e => 2-working-with-data/src/test/java/com/webforj/tutorial}/Step2IT.java (93%) create mode 100644 2-working-with-data/src/test/java/com/webforj/tutorial/TutorialApp.java create mode 100644 2-working-with-data/src/test/java/com/webforj/tutorial/VisualAssertions.java rename {e2e => 2-working-with-data}/src/test/resources/screenshots/customer-table.png (100%) create mode 100644 3-routing-and-composites/src/test/java/com/webforj/tutorial/BaseTest.java rename {e2e/src/test/java/com/webforj/tutorial/e2e => 3-routing-and-composites/src/test/java/com/webforj/tutorial}/Step3IT.java (92%) create mode 100644 3-routing-and-composites/src/test/java/com/webforj/tutorial/TutorialApp.java create mode 100644 3-routing-and-composites/src/test/java/com/webforj/tutorial/VisualAssertions.java rename {e2e => 3-routing-and-composites}/src/test/resources/screenshots/completed-customer-form.png (100%) create mode 100644 4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/BaseTest.java rename {e2e/src/test/java/com/webforj/tutorial/e2e => 4-observers-and-route-parameters/src/test/java/com/webforj/tutorial}/Step4IT.java (92%) create mode 100644 4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/TutorialApp.java create mode 100644 4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/VisualAssertions.java rename {e2e => 4-observers-and-route-parameters}/src/test/resources/screenshots/edit-customer-route.png (100%) create mode 100644 5-validating-and-binding-data/src/test/java/com/webforj/tutorial/BaseTest.java rename {e2e/src/test/java/com/webforj/tutorial/e2e => 5-validating-and-binding-data/src/test/java/com/webforj/tutorial}/Step5IT.java (92%) create mode 100644 5-validating-and-binding-data/src/test/java/com/webforj/tutorial/TutorialApp.java create mode 100644 5-validating-and-binding-data/src/test/java/com/webforj/tutorial/VisualAssertions.java rename {e2e => 5-validating-and-binding-data}/src/test/resources/screenshots/valid-customer-form.png (100%) create mode 100644 6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/BaseTest.java rename {e2e/src/test/java/com/webforj/tutorial/e2e => 6-integrating-an-app-layout/src/test/java/com/webforj/tutorial}/Step6IT.java (95%) create mode 100644 6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/TutorialApp.java create mode 100644 6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/VisualAssertions.java rename {e2e => 6-integrating-an-app-layout}/src/test/resources/screenshots/dashboard-layout.png (100%) rename {e2e => 6-integrating-an-app-layout}/src/test/resources/screenshots/dashboard-mobile.png (100%) delete mode 100644 Dockerfile delete mode 100644 pom.xml create mode 100644 scripts/run-e2e.sh diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 93462ff..0000000 --- a/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -.git -.idea -**/target -node_modules -playwright-report -test-results diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 76faec9..348d2e1 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -7,10 +7,18 @@ on: - cron: '0 7 * * *' workflow_dispatch: inputs: - it_test: - description: 'Test filter passed to -Dit.test (e.g. Step3IT)' - required: false - default: '*IT' + step: + description: 'Tutorial step to test' + type: choice + default: all + options: + - all + - '1' + - '2' + - '3' + - '4' + - '5' + - '6' jobs: e2e: @@ -30,7 +38,9 @@ jobs: cache: maven - name: Run end-to-end test suite - run: mvn -ntp -B verify -Dit.test='${{ inputs.it_test || '*IT' }}' + env: + E2E_STEP: ${{ inputs.step || 'all' }} + run: bash scripts/run-e2e.sh "$E2E_STEP" - name: Upload failure artifacts if: always() @@ -38,8 +48,9 @@ jobs: with: name: e2e-artifacts path: | - target/visual-diffs - target/playwright-traces - target/e2e-artifacts + */target/failsafe-reports + */target/visual-diffs + */target/playwright-traces + */target/e2e-artifacts if-no-files-found: ignore retention-days: 14 diff --git a/1-creating-a-basic-app/README.md b/1-creating-a-basic-app/README.md index 6b97b67..19d53c1 100644 --- a/1-creating-a-basic-app/README.md +++ b/1-creating-a-basic-app/README.md @@ -14,6 +14,7 @@ To run the app, ensure the following tools are installed: - Java 21 or higher - BBj 26.02 when running with local BBjServices - Maven +- Docker Desktop (or another Docker engine) for end-to-end tests - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser - Git (recommended) @@ -49,6 +50,24 @@ webforj-tutorial ``` 3. Open your browser and go to [http://localhost:8080](http://localhost:8080). +## End-to-End and Screenshot Tests + +With Docker running, execute this command from this step's directory: + +```sh +mvn verify +``` + +Maven launches the step's Java Playwright tests in the pinned Docker image. Only this step is built and tested. Tests are in `src/test/java/com/webforj/tutorial`, screenshot baselines in `src/test/resources/screenshots`, and reports, traces, and logs under `target`. + +To update this step's baselines after an intentional visual change: + +```sh +mvn verify -DupdateScreenshots=true +``` + +Both test execution and baseline generation happen in Docker. `mvn test` runs unit tests; the end-to-end tests run during `mvn verify`. + ## Project Highlights - **Spring Boot integration:** Autowire Spring beans directly into webforJ views and components. diff --git a/1-creating-a-basic-app/pom.xml b/1-creating-a-basic-app/pom.xml index b66e0e8..bc0d243 100644 --- a/1-creating-a-basic-app/pom.xml +++ b/1-creating-a-basic-app/pom.xml @@ -18,6 +18,13 @@ 21 21 UTF-8 + 1.50.0 + 5.11.4 + *IT + false + false + ${maven.test.skip} + ${skipTests} @@ -53,7 +60,12 @@ com.microsoft.playwright playwright - 1.49.0 + ${playwright.version} + test + + + org.junit.jupiter + junit-jupiter test @@ -80,20 +92,6 @@ - - maven-failsafe-plugin - - - - integration-test - verify - - - - - false - - @@ -107,5 +105,99 @@ true + + + docker-e2e + + + !docker.e2e.container + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + docker-e2e + verify + exec + + ${skipITs} + docker + + run + --rm + --init + --ipc=host + --workdir=/app + -e + CI=true + -e + E2E_IN_DOCKER=true + --mount=type=bind,source=${project.basedir},target=/app + -v + webforj-tutorial-e2e-maven-repo:/var/maven/.m2 + -e + MAVEN_CONFIG=/var/maven/.m2 + mcr.microsoft.com/playwright/java:v${playwright.version}-noble + mvn + -B + -ntp + -Dmaven.repo.local=/var/maven/.m2/repository + -Ddocker.e2e.container=true + -DupdateScreenshots=${updateScreenshots} + -Dit.test=${it.test} + clean + verify + + + + + + + + + + + container-e2e + + + docker.e2e.container + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + + integration-test + verify + + + + + true + false + false + + ${project.basedir} + ${project.build.directory}/${project.build.finalName}.jar + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + + + + + + diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/BaseTest.java b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/BaseTest.java similarity index 96% rename from e2e/src/test/java/com/webforj/tutorial/e2e/BaseTest.java rename to 1-creating-a-basic-app/src/test/java/com/webforj/tutorial/BaseTest.java index 79469ba..f34110d 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/BaseTest.java +++ b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/BaseTest.java @@ -1,4 +1,4 @@ -package com.webforj.tutorial.e2e; +package com.webforj.tutorial; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -38,10 +38,9 @@ abstract class BaseTest { protected Page page; - protected abstract String stepDirectory(); - @BeforeAll void startBrowser() { + TutorialApp.requireDocker(); playwright = Playwright.create(); browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); PlaywrightAssertions.setDefaultAssertionTimeout(10_000); @@ -50,7 +49,7 @@ void startBrowser() { @BeforeEach void startApplicationAndCreateBrowserContext() throws Exception { browserErrors.clear(); - application = TutorialApp.start(stepDirectory()); + application = TutorialApp.start(); context = browser.newContext(new Browser.NewContextOptions() .setViewportSize(1440, 900) .setDeviceScaleFactor(1) @@ -74,7 +73,7 @@ void closeBrowserContext(TestInfo testInfo) throws IOException { List errorsBeforeClose = List.copyOf(browserErrors); try { if (context != null) { - Path traceDirectory = Path.of("target", "playwright-traces"); + Path traceDirectory = Path.of(System.getProperty("e2e.output.dir"), "playwright-traces"); Files.createDirectories(traceDirectory); String testName = (getClass().getSimpleName() + "-" + testInfo.getDisplayName()) .replaceAll("[^a-zA-Z0-9.-]", "-"); diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step1IT.java b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/Step1IT.java similarity index 88% rename from e2e/src/test/java/com/webforj/tutorial/e2e/Step1IT.java rename to 1-creating-a-basic-app/src/test/java/com/webforj/tutorial/Step1IT.java index 6e80204..f041393 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/Step1IT.java +++ b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/Step1IT.java @@ -1,4 +1,4 @@ -package com.webforj.tutorial.e2e; +package com.webforj.tutorial; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; @@ -8,11 +8,6 @@ import org.junit.jupiter.api.Test; class Step1IT extends BaseTest { - @Override - protected String stepDirectory() { - return "1-creating-a-basic-app"; - } - @Test void rendersBasicApplicationAndOpensInformationDialog() throws IOException { openApplication(); diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/TutorialApp.java b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/TutorialApp.java similarity index 74% rename from e2e/src/test/java/com/webforj/tutorial/e2e/TutorialApp.java rename to 1-creating-a-basic-app/src/test/java/com/webforj/tutorial/TutorialApp.java index 15c5d70..9d3ae51 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/TutorialApp.java +++ b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/TutorialApp.java @@ -1,4 +1,4 @@ -package com.webforj.tutorial.e2e; +package com.webforj.tutorial; import java.io.IOException; import java.net.ServerSocket; @@ -10,7 +10,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; -import java.util.Comparator; import java.util.List; import java.util.concurrent.TimeUnit; @@ -30,21 +29,19 @@ private TutorialApp(Process process, String baseUrl, Path logFile) { this.logFile = logFile; } - static TutorialApp start(String stepDirectory) throws Exception { + static TutorialApp start() throws Exception { requireDocker(); - Path repositoryRoot = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); - Path stepPath = repositoryRoot.resolve(stepDirectory).normalize(); - if (!stepPath.startsWith(repositoryRoot) || !Files.isDirectory(stepPath)) { - throw new IllegalArgumentException("Invalid tutorial step directory: " + stepDirectory); + Path stepPath = Path.of(System.getProperty("e2e.app.dir")).toAbsolutePath().normalize(); + Path jar = Path.of(System.getProperty("e2e.app.jar")).toAbsolutePath().normalize(); + if (!Files.isRegularFile(jar)) { + throw new IllegalStateException("Application JAR does not exist: " + jar + + ". Run the complete Maven verify lifecycle to package the application first."); } - Path jar = findApplicationJar(stepPath.resolve("target")); int port = findFreePort(); String baseUrl = "http://127.0.0.1:" + port; - Path logFile = repositoryRoot.resolve("target/e2e-artifacts") - .resolve(stepDirectory) - .resolve("server.log"); + Path logFile = Path.of(System.getProperty("e2e.output.dir"), "e2e-artifacts", "server.log"); Files.createDirectories(logFile.getParent()); String javaExecutable = Path.of(System.getProperty("java.home"), "bin", "java").toString(); @@ -79,31 +76,10 @@ String baseUrl() { return baseUrl; } - private static void requireDocker() { + static void requireDocker() { if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { throw new IllegalStateException( - "E2E tests may only run in Docker. Run `mvn verify` from the repository root."); - } - } - - private static Path findApplicationJar(Path targetDirectory) throws IOException { - if (!Files.isDirectory(targetDirectory)) { - throw new IllegalStateException("Application target directory does not exist: " + targetDirectory); - } - - try (var files = Files.list(targetDirectory)) { - List jars = files - .filter(Files::isRegularFile) - .filter(path -> path.getFileName().toString().endsWith(".jar")) - .filter(path -> !path.getFileName().toString().endsWith(".jar.original")) - .sorted(Comparator.comparing(Path::toString)) - .toList(); - if (jars.size() != 1) { - throw new IllegalStateException( - "Expected exactly one executable application JAR in " + targetDirectory - + ", found " + jars.size()); - } - return jars.getFirst(); + "E2E tests may only run in Docker. Run `mvn verify` from this tutorial step's directory."); } } diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/VisualAssertions.java b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/VisualAssertions.java similarity index 99% rename from e2e/src/test/java/com/webforj/tutorial/e2e/VisualAssertions.java rename to 1-creating-a-basic-app/src/test/java/com/webforj/tutorial/VisualAssertions.java index 2100277..c42d6b9 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/VisualAssertions.java +++ b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/VisualAssertions.java @@ -1,4 +1,4 @@ -package com.webforj.tutorial.e2e; +package com.webforj.tutorial; import static org.junit.jupiter.api.Assertions.fail; diff --git a/e2e/src/test/resources/screenshots/basic-application-dialog.png b/1-creating-a-basic-app/src/test/resources/screenshots/basic-application-dialog.png similarity index 100% rename from e2e/src/test/resources/screenshots/basic-application-dialog.png rename to 1-creating-a-basic-app/src/test/resources/screenshots/basic-application-dialog.png diff --git a/2-working-with-data/README.md b/2-working-with-data/README.md index 6b97b67..19d53c1 100644 --- a/2-working-with-data/README.md +++ b/2-working-with-data/README.md @@ -14,6 +14,7 @@ To run the app, ensure the following tools are installed: - Java 21 or higher - BBj 26.02 when running with local BBjServices - Maven +- Docker Desktop (or another Docker engine) for end-to-end tests - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser - Git (recommended) @@ -49,6 +50,24 @@ webforj-tutorial ``` 3. Open your browser and go to [http://localhost:8080](http://localhost:8080). +## End-to-End and Screenshot Tests + +With Docker running, execute this command from this step's directory: + +```sh +mvn verify +``` + +Maven launches the step's Java Playwright tests in the pinned Docker image. Only this step is built and tested. Tests are in `src/test/java/com/webforj/tutorial`, screenshot baselines in `src/test/resources/screenshots`, and reports, traces, and logs under `target`. + +To update this step's baselines after an intentional visual change: + +```sh +mvn verify -DupdateScreenshots=true +``` + +Both test execution and baseline generation happen in Docker. `mvn test` runs unit tests; the end-to-end tests run during `mvn verify`. + ## Project Highlights - **Spring Boot integration:** Autowire Spring beans directly into webforJ views and components. diff --git a/2-working-with-data/pom.xml b/2-working-with-data/pom.xml index 82735d0..857abf5 100644 --- a/2-working-with-data/pom.xml +++ b/2-working-with-data/pom.xml @@ -18,6 +18,13 @@ 21 21 UTF-8 + 1.50.0 + 5.11.4 + *IT + false + false + ${maven.test.skip} + ${skipTests} @@ -58,6 +65,17 @@ com.h2database h2 + + com.microsoft.playwright + playwright + ${playwright.version} + test + + + org.junit.jupiter + junit-jupiter + test + compile webforj:watch spring-boot:run @@ -82,20 +100,6 @@ - - maven-failsafe-plugin - - - - integration-test - verify - - - - - false - - @@ -109,5 +113,99 @@ true + + + docker-e2e + + + !docker.e2e.container + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + docker-e2e + verify + exec + + ${skipITs} + docker + + run + --rm + --init + --ipc=host + --workdir=/app + -e + CI=true + -e + E2E_IN_DOCKER=true + --mount=type=bind,source=${project.basedir},target=/app + -v + webforj-tutorial-e2e-maven-repo:/var/maven/.m2 + -e + MAVEN_CONFIG=/var/maven/.m2 + mcr.microsoft.com/playwright/java:v${playwright.version}-noble + mvn + -B + -ntp + -Dmaven.repo.local=/var/maven/.m2/repository + -Ddocker.e2e.container=true + -DupdateScreenshots=${updateScreenshots} + -Dit.test=${it.test} + clean + verify + + + + + + + + + + + container-e2e + + + docker.e2e.container + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + + integration-test + verify + + + + + true + false + false + + ${project.basedir} + ${project.build.directory}/${project.build.finalName}.jar + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + + + + + + diff --git a/2-working-with-data/src/test/java/com/webforj/tutorial/BaseTest.java b/2-working-with-data/src/test/java/com/webforj/tutorial/BaseTest.java new file mode 100644 index 0000000..f34110d --- /dev/null +++ b/2-working-with-data/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,155 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.ConsoleMessage; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.Request; +import com.microsoft.playwright.Tracing; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.AriaRole; +import com.microsoft.playwright.options.ColorScheme; +import com.microsoft.playwright.options.WaitUntilState; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class BaseTest { + private Playwright playwright; + private Browser browser; + private BrowserContext context; + private TutorialApp application; + private final List browserErrors = new ArrayList<>(); + + protected Page page; + + @BeforeAll + void startBrowser() { + TutorialApp.requireDocker(); + playwright = Playwright.create(); + browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + PlaywrightAssertions.setDefaultAssertionTimeout(10_000); + } + + @BeforeEach + void startApplicationAndCreateBrowserContext() throws Exception { + browserErrors.clear(); + application = TutorialApp.start(); + context = browser.newContext(new Browser.NewContextOptions() + .setViewportSize(1440, 900) + .setDeviceScaleFactor(1) + .setColorScheme(ColorScheme.LIGHT) + .setLocale("en-US") + .setTimezoneId("UTC") + .setIgnoreHTTPSErrors(true)); + context.tracing().start(new Tracing.StartOptions() + .setScreenshots(true) + .setSnapshots(true) + .setSources(true)); + + page = context.newPage(); + page.onPageError(error -> browserErrors.add("pageerror: " + error)); + page.onConsoleMessage(this::recordConsoleError); + page.onRequestFailed(this::recordRequestFailure); + } + + @AfterEach + void closeBrowserContext(TestInfo testInfo) throws IOException { + List errorsBeforeClose = List.copyOf(browserErrors); + try { + if (context != null) { + Path traceDirectory = Path.of(System.getProperty("e2e.output.dir"), "playwright-traces"); + Files.createDirectories(traceDirectory); + String testName = (getClass().getSimpleName() + "-" + testInfo.getDisplayName()) + .replaceAll("[^a-zA-Z0-9.-]", "-"); + context.tracing().stop(new Tracing.StopOptions() + .setPath(traceDirectory.resolve(testName + ".zip"))); + context.close(); + context = null; + } + } finally { + if (application != null) { + application.close(); + application = null; + } + } + assertTrue(errorsBeforeClose.isEmpty(), + () -> "Browser runtime/network errors:\n" + String.join("\n", errorsBeforeClose)); + } + + @AfterAll + void stopBrowser() { + if (browser != null) { + browser.close(); + } + if (playwright != null) { + playwright.close(); + } + } + + protected void openApplication() { + openApplication("/"); + } + + protected void openApplication(String path) { + String normalizedPath = path.startsWith("/") ? path : "/" + path; + page.navigate(application.baseUrl() + normalizedPath, + new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + } + + protected Locator customerTable() { + return page.locator("dwc-table"); + } + + protected Locator expectCustomerTable() { + Locator table = customerTable(); + assertThat(table).isVisible(); + assertThat(table).containsText("First Name"); + assertThat(table).containsText("Last Name"); + assertThat(table).containsText("Company"); + assertThat(table).containsText("Country"); + assertThat(table).containsText("Alice"); + assertThat(table).containsText("TechCorp"); + return table; + } + + protected Locator expectCustomerRow(String identifyingCellText) { + Locator identifyingCell = page.getByRole(AriaRole.CELL, + new Page.GetByRoleOptions().setName(identifyingCellText).setExact(true)); + Locator row = page.getByRole(AriaRole.ROW) + .filter(new Locator.FilterOptions().setHas(identifyingCell)); + assertThat(row).hasCount(1); + return row; + } + + private void recordConsoleError(ConsoleMessage message) { + if ("error".equals(message.type())) { + browserErrors.add("console: " + message.text()); + } + } + + private void recordRequestFailure(Request request) { + String failure = request.failure(); + boolean cancelledWebforjPoll = "net::ERR_ABORTED".equals(failure) + && request.url().contains("/webforjServlet/webapprmi"); + if (!cancelledWebforjPoll) { + browserErrors.add( + "requestfailed: " + request.method() + " " + request.url() + " (" + failure + ")"); + } + } +} diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step2IT.java b/2-working-with-data/src/test/java/com/webforj/tutorial/Step2IT.java similarity index 93% rename from e2e/src/test/java/com/webforj/tutorial/e2e/Step2IT.java rename to 2-working-with-data/src/test/java/com/webforj/tutorial/Step2IT.java index d6702a9..7874f58 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/Step2IT.java +++ b/2-working-with-data/src/test/java/com/webforj/tutorial/Step2IT.java @@ -1,4 +1,4 @@ -package com.webforj.tutorial.e2e; +package com.webforj.tutorial; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; @@ -9,11 +9,6 @@ import org.junit.jupiter.api.Test; class Step2IT extends BaseTest { - @Override - protected String stepDirectory() { - return "2-working-with-data"; - } - @Test void rendersSeededDataDialogAndSorting() throws IOException { openApplication(); diff --git a/2-working-with-data/src/test/java/com/webforj/tutorial/TutorialApp.java b/2-working-with-data/src/test/java/com/webforj/tutorial/TutorialApp.java new file mode 100644 index 0000000..9d3ae51 --- /dev/null +++ b/2-working-with-data/src/test/java/com/webforj/tutorial/TutorialApp.java @@ -0,0 +1,159 @@ +package com.webforj.tutorial; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; + +final class TutorialApp implements AutoCloseable { + private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(3); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .build(); + + private final Process process; + private final String baseUrl; + private final Path logFile; + + private TutorialApp(Process process, String baseUrl, Path logFile) { + this.process = process; + this.baseUrl = baseUrl; + this.logFile = logFile; + } + + static TutorialApp start() throws Exception { + requireDocker(); + + Path stepPath = Path.of(System.getProperty("e2e.app.dir")).toAbsolutePath().normalize(); + Path jar = Path.of(System.getProperty("e2e.app.jar")).toAbsolutePath().normalize(); + if (!Files.isRegularFile(jar)) { + throw new IllegalStateException("Application JAR does not exist: " + jar + + ". Run the complete Maven verify lifecycle to package the application first."); + } + + int port = findFreePort(); + String baseUrl = "http://127.0.0.1:" + port; + Path logFile = Path.of(System.getProperty("e2e.output.dir"), "e2e-artifacts", "server.log"); + Files.createDirectories(logFile.getParent()); + + String javaExecutable = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + ProcessBuilder processBuilder = new ProcessBuilder( + javaExecutable, + "-jar", + jar.toString(), + "--server.port=" + port, + "--server.address=127.0.0.1", + "--spring.jpa.hibernate.ddl-auto=create-drop", + "--webforj.devtools.browser.open=false", + "--webforj.devtools.livereload.enabled=false", + "--webforj.devtools.livereload.static-resources-enabled=false", + "--webforj.devtools.craftforj.enabled=false", + "--webforj.debug=false"); + processBuilder.directory(stepPath.toFile()); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(logFile.toFile()); + + Process process = processBuilder.start(); + TutorialApp app = new TutorialApp(process, baseUrl, logFile); + try { + app.waitUntilReady(); + return app; + } catch (Exception exception) { + app.close(); + throw exception; + } + } + + String baseUrl() { + return baseUrl; + } + + static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException( + "E2E tests may only run in Docker. Run `mvn verify` from this tutorial step's directory."); + } + } + + private static int findFreePort() throws IOException { + for (int preferredPort : List.of(8080, 8090)) { + try (ServerSocket ignored = new ServerSocket(preferredPort)) { + return preferredPort; + } catch (IOException ignored) { + // Try the next preferred port. + } + } + + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void waitUntilReady() throws Exception { + long deadline = System.nanoTime() + STARTUP_TIMEOUT.toNanos(); + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/")) + .timeout(Duration.ofSeconds(3)) + .GET() + .build(); + + while (System.nanoTime() < deadline) { + if (!process.isAlive()) { + throw new IllegalStateException( + "Application exited before becoming ready.\n" + tailLog()); + } + + try { + HttpResponse response = HTTP_CLIENT.send( + request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() < 500) { + return; + } + } catch (IOException ignored) { + // The server is still starting. + } + + Thread.sleep(500); + } + + throw new IllegalStateException( + "Application did not become ready within " + STARTUP_TIMEOUT + ".\n" + tailLog()); + } + + private String tailLog() { + try { + List lines = Files.readAllLines(logFile, StandardCharsets.UTF_8); + int start = Math.max(0, lines.size() - 100); + return "Server log: " + logFile + "\n" + String.join("\n", lines.subList(start, lines.size())); + } catch (IOException exception) { + return "Could not read server log " + logFile + ": " + exception.getMessage(); + } + } + + @Override + public void close() { + if (!process.isAlive()) { + return; + } + + process.descendants().forEach(ProcessHandle::destroy); + process.destroy(); + try { + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } +} diff --git a/2-working-with-data/src/test/java/com/webforj/tutorial/VisualAssertions.java b/2-working-with-data/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..c42d6b9 --- /dev/null +++ b/2-working-with-data/src/test/java/com/webforj/tutorial/VisualAssertions.java @@ -0,0 +1,198 @@ +package com.webforj.tutorial; + +import static org.junit.jupiter.api.Assertions.fail; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import javax.imageio.ImageIO; + +final class VisualAssertions { + private static final int DEFAULT_MAX_DIFFERENT_PIXELS = 500; + private static final int CHANNEL_THRESHOLD = 51; + private static final int DEFAULT_TIMEOUT_MS = 10_000; + private static final int POLL_INTERVAL_MS = 250; + + /** + * Every {@code dwc-icon} resolves its artwork over the network and injects an {@code } into + * its own shadow root, so an unresolved icon is invisible in a screenshot while the surrounding + * DOM already looks complete. Icons live inside nested shadow roots, hence the manual walk. + */ + private static final String ICONS_RENDERED = """ + () => { + const icons = []; + const walk = (root) => { + for (const element of root.querySelectorAll('*')) { + if (element.localName === 'dwc-icon') { + icons.push(element); + } + if (element.shadowRoot) { + walk(element.shadowRoot); + } + } + }; + walk(document); + return icons.every(icon => icon.shadowRoot && icon.shadowRoot.querySelector('svg')); + } + """; + + private VisualAssertions() { + } + + static void assertScreenshot(Page page, String name) throws IOException { + requirePngName(name); + awaitRenderedIcons(page); + + Path baselineDirectory = Path.of(requiredProperty("visual.baseline.dir")); + Path artifactDirectory = Path.of(requiredProperty("visual.artifact.dir")); + Path baseline = baselineDirectory.resolve(name); + byte[] screenshot = captureStable(page, name, artifactDirectory); + + if (Boolean.getBoolean("updateScreenshots")) { + requireDocker(); + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing Docker screenshot baseline " + baseline + + ". Generate it with `mvn verify -DupdateScreenshots=true`."); + } + + BufferedImage expected = ImageIO.read(baseline.toFile()); + if (expected == null) { + fail("Could not decode screenshot baseline for " + name); + } + + BufferedImage actual = ImageIO.read(new ByteArrayInputStream(screenshot)); + if (actual == null) { + fail("Could not decode captured screenshot for " + name); + } + + if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { + writeFailureArtifacts(artifactDirectory, name, screenshot, null); + fail("Screenshot dimensions differ for " + name + ": expected " + + expected.getWidth() + "x" + expected.getHeight() + ", actual " + + actual.getWidth() + "x" + actual.getHeight()); + } + + BufferedImage diff = new BufferedImage( + actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); + int differentPixels = comparePixels(expected, actual, diff); + int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); + if (differentPixels > maximum) { + writeFailureArtifacts(artifactDirectory, name, screenshot, diff); + fail("Screenshot mismatch for " + name + ": " + differentPixels + + " pixels differ (maximum " + maximum + "). See " + artifactDirectory); + } + } + + private static void awaitRenderedIcons(Page page) { + try { + page.waitForFunction(ICONS_RENDERED, null, + new Page.WaitForFunctionOptions().setTimeout(timeoutMs())); + } catch (RuntimeException e) { + fail("Icons did not finish rendering within " + timeoutMs() + + " ms; screenshots would capture missing icons. Icon artwork is fetched from" + + " cdn.jsdelivr.net, so check network access from the container.", e); + } + } + + private static byte[] capture(Page page) { + page.evaluate("() => document.fonts.ready"); + return page.screenshot(new Page.ScreenshotOptions() + .setAnimations(ScreenshotAnimations.DISABLED) + .setCaret(ScreenshotCaret.HIDE) + .setScale(ScreenshotScale.CSS)); + } + + private static byte[] captureStable( + Page page, String name, Path artifactDirectory) throws IOException { + long deadline = System.nanoTime() + timeoutMs() * 1_000_000L; + byte[] previous = capture(page); + while (System.nanoTime() < deadline) { + page.waitForTimeout(POLL_INTERVAL_MS); + byte[] current = capture(page); + if (Arrays.equals(previous, current)) { + return current; + } + previous = current; + } + + writeFailureArtifacts(artifactDirectory, name, previous, null); + return fail("Page did not produce two consecutive identical screenshots for " + name + + " within " + timeoutMs() + " ms. Refusing to compare or update an unstable image. See " + + artifactDirectory); + } + + private static int comparePixels(BufferedImage expected, BufferedImage actual, BufferedImage diff) { + int differentPixels = 0; + for (int y = 0; y < actual.getHeight(); y++) { + for (int x = 0; x < actual.getWidth(); x++) { + int expectedRgb = expected.getRGB(x, y); + int actualRgb = actual.getRGB(x, y); + if (isDifferent(expectedRgb, actualRgb)) { + differentPixels++; + diff.setRGB(x, y, Color.MAGENTA.getRGB()); + } else { + Color pixel = new Color(actualRgb, true); + int gray = (pixel.getRed() + pixel.getGreen() + pixel.getBlue()) / 3; + diff.setRGB(x, y, new Color(gray, gray, gray, 110).getRGB()); + } + } + } + return differentPixels; + } + + private static int timeoutMs() { + return Integer.getInteger("visual.timeoutMs", DEFAULT_TIMEOUT_MS); + } + + private static boolean isDifferent(int expectedRgb, int actualRgb) { + Color expected = new Color(expectedRgb, true); + Color actual = new Color(actualRgb, true); + return Math.abs(expected.getRed() - actual.getRed()) > CHANNEL_THRESHOLD + || Math.abs(expected.getGreen() - actual.getGreen()) > CHANNEL_THRESHOLD + || Math.abs(expected.getBlue() - actual.getBlue()) > CHANNEL_THRESHOLD + || Math.abs(expected.getAlpha() - actual.getAlpha()) > CHANNEL_THRESHOLD; + } + + private static void writeFailureArtifacts( + Path artifactDirectory, String name, byte[] screenshot, BufferedImage diff) throws IOException { + Files.createDirectories(artifactDirectory); + String stem = name.substring(0, name.length() - ".png".length()); + Files.write(artifactDirectory.resolve(stem + "-actual.png"), screenshot); + if (diff != null) { + ImageIO.write(diff, "png", artifactDirectory.resolve(stem + "-diff.png").toFile()); + } + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required system property: " + name); + } + return value; + } + + private static void requirePngName(String name) { + if (!name.matches("[a-z0-9-]+\\.png")) { + throw new IllegalArgumentException("Screenshot name must be a simple kebab-case PNG filename: " + name); + } + } + + private static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException("Screenshot baselines may only be generated in Docker."); + } + } +} diff --git a/e2e/src/test/resources/screenshots/customer-table.png b/2-working-with-data/src/test/resources/screenshots/customer-table.png similarity index 100% rename from e2e/src/test/resources/screenshots/customer-table.png rename to 2-working-with-data/src/test/resources/screenshots/customer-table.png diff --git a/3-routing-and-composites/README.md b/3-routing-and-composites/README.md index 6b97b67..19d53c1 100644 --- a/3-routing-and-composites/README.md +++ b/3-routing-and-composites/README.md @@ -14,6 +14,7 @@ To run the app, ensure the following tools are installed: - Java 21 or higher - BBj 26.02 when running with local BBjServices - Maven +- Docker Desktop (or another Docker engine) for end-to-end tests - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser - Git (recommended) @@ -49,6 +50,24 @@ webforj-tutorial ``` 3. Open your browser and go to [http://localhost:8080](http://localhost:8080). +## End-to-End and Screenshot Tests + +With Docker running, execute this command from this step's directory: + +```sh +mvn verify +``` + +Maven launches the step's Java Playwright tests in the pinned Docker image. Only this step is built and tested. Tests are in `src/test/java/com/webforj/tutorial`, screenshot baselines in `src/test/resources/screenshots`, and reports, traces, and logs under `target`. + +To update this step's baselines after an intentional visual change: + +```sh +mvn verify -DupdateScreenshots=true +``` + +Both test execution and baseline generation happen in Docker. `mvn test` runs unit tests; the end-to-end tests run during `mvn verify`. + ## Project Highlights - **Spring Boot integration:** Autowire Spring beans directly into webforJ views and components. diff --git a/3-routing-and-composites/pom.xml b/3-routing-and-composites/pom.xml index 89ae77f..acf6fd6 100644 --- a/3-routing-and-composites/pom.xml +++ b/3-routing-and-composites/pom.xml @@ -18,6 +18,13 @@ 21 21 UTF-8 + 1.50.0 + 5.11.4 + *IT + false + false + ${maven.test.skip} + ${skipTests} @@ -58,6 +65,17 @@ com.h2database h2 + + com.microsoft.playwright + playwright + ${playwright.version} + test + + + org.junit.jupiter + junit-jupiter + test + compile webforj:watch spring-boot:run @@ -99,5 +117,99 @@ true + + + docker-e2e + + + !docker.e2e.container + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + docker-e2e + verify + exec + + ${skipITs} + docker + + run + --rm + --init + --ipc=host + --workdir=/app + -e + CI=true + -e + E2E_IN_DOCKER=true + --mount=type=bind,source=${project.basedir},target=/app + -v + webforj-tutorial-e2e-maven-repo:/var/maven/.m2 + -e + MAVEN_CONFIG=/var/maven/.m2 + mcr.microsoft.com/playwright/java:v${playwright.version}-noble + mvn + -B + -ntp + -Dmaven.repo.local=/var/maven/.m2/repository + -Ddocker.e2e.container=true + -DupdateScreenshots=${updateScreenshots} + -Dit.test=${it.test} + clean + verify + + + + + + + + + + + container-e2e + + + docker.e2e.container + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + + integration-test + verify + + + + + true + false + false + + ${project.basedir} + ${project.build.directory}/${project.build.finalName}.jar + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + + + + + + diff --git a/3-routing-and-composites/src/test/java/com/webforj/tutorial/BaseTest.java b/3-routing-and-composites/src/test/java/com/webforj/tutorial/BaseTest.java new file mode 100644 index 0000000..f34110d --- /dev/null +++ b/3-routing-and-composites/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,155 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.ConsoleMessage; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.Request; +import com.microsoft.playwright.Tracing; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.AriaRole; +import com.microsoft.playwright.options.ColorScheme; +import com.microsoft.playwright.options.WaitUntilState; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class BaseTest { + private Playwright playwright; + private Browser browser; + private BrowserContext context; + private TutorialApp application; + private final List browserErrors = new ArrayList<>(); + + protected Page page; + + @BeforeAll + void startBrowser() { + TutorialApp.requireDocker(); + playwright = Playwright.create(); + browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + PlaywrightAssertions.setDefaultAssertionTimeout(10_000); + } + + @BeforeEach + void startApplicationAndCreateBrowserContext() throws Exception { + browserErrors.clear(); + application = TutorialApp.start(); + context = browser.newContext(new Browser.NewContextOptions() + .setViewportSize(1440, 900) + .setDeviceScaleFactor(1) + .setColorScheme(ColorScheme.LIGHT) + .setLocale("en-US") + .setTimezoneId("UTC") + .setIgnoreHTTPSErrors(true)); + context.tracing().start(new Tracing.StartOptions() + .setScreenshots(true) + .setSnapshots(true) + .setSources(true)); + + page = context.newPage(); + page.onPageError(error -> browserErrors.add("pageerror: " + error)); + page.onConsoleMessage(this::recordConsoleError); + page.onRequestFailed(this::recordRequestFailure); + } + + @AfterEach + void closeBrowserContext(TestInfo testInfo) throws IOException { + List errorsBeforeClose = List.copyOf(browserErrors); + try { + if (context != null) { + Path traceDirectory = Path.of(System.getProperty("e2e.output.dir"), "playwright-traces"); + Files.createDirectories(traceDirectory); + String testName = (getClass().getSimpleName() + "-" + testInfo.getDisplayName()) + .replaceAll("[^a-zA-Z0-9.-]", "-"); + context.tracing().stop(new Tracing.StopOptions() + .setPath(traceDirectory.resolve(testName + ".zip"))); + context.close(); + context = null; + } + } finally { + if (application != null) { + application.close(); + application = null; + } + } + assertTrue(errorsBeforeClose.isEmpty(), + () -> "Browser runtime/network errors:\n" + String.join("\n", errorsBeforeClose)); + } + + @AfterAll + void stopBrowser() { + if (browser != null) { + browser.close(); + } + if (playwright != null) { + playwright.close(); + } + } + + protected void openApplication() { + openApplication("/"); + } + + protected void openApplication(String path) { + String normalizedPath = path.startsWith("/") ? path : "/" + path; + page.navigate(application.baseUrl() + normalizedPath, + new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + } + + protected Locator customerTable() { + return page.locator("dwc-table"); + } + + protected Locator expectCustomerTable() { + Locator table = customerTable(); + assertThat(table).isVisible(); + assertThat(table).containsText("First Name"); + assertThat(table).containsText("Last Name"); + assertThat(table).containsText("Company"); + assertThat(table).containsText("Country"); + assertThat(table).containsText("Alice"); + assertThat(table).containsText("TechCorp"); + return table; + } + + protected Locator expectCustomerRow(String identifyingCellText) { + Locator identifyingCell = page.getByRole(AriaRole.CELL, + new Page.GetByRoleOptions().setName(identifyingCellText).setExact(true)); + Locator row = page.getByRole(AriaRole.ROW) + .filter(new Locator.FilterOptions().setHas(identifyingCell)); + assertThat(row).hasCount(1); + return row; + } + + private void recordConsoleError(ConsoleMessage message) { + if ("error".equals(message.type())) { + browserErrors.add("console: " + message.text()); + } + } + + private void recordRequestFailure(Request request) { + String failure = request.failure(); + boolean cancelledWebforjPoll = "net::ERR_ABORTED".equals(failure) + && request.url().contains("/webforjServlet/webapprmi"); + if (!cancelledWebforjPoll) { + browserErrors.add( + "requestfailed: " + request.method() + " " + request.url() + " (" + failure + ")"); + } + } +} diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step3IT.java b/3-routing-and-composites/src/test/java/com/webforj/tutorial/Step3IT.java similarity index 92% rename from e2e/src/test/java/com/webforj/tutorial/e2e/Step3IT.java rename to 3-routing-and-composites/src/test/java/com/webforj/tutorial/Step3IT.java index 48d1334..dd5f62a 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/Step3IT.java +++ b/3-routing-and-composites/src/test/java/com/webforj/tutorial/Step3IT.java @@ -1,4 +1,4 @@ -package com.webforj.tutorial.e2e; +package com.webforj.tutorial; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; @@ -10,11 +10,6 @@ import org.junit.jupiter.api.Test; class Step3IT extends BaseTest { - @Override - protected String stepDirectory() { - return "3-routing-and-composites"; - } - @Test void createsCustomerAndReturnsToTable() throws IOException { openApplication(); diff --git a/3-routing-and-composites/src/test/java/com/webforj/tutorial/TutorialApp.java b/3-routing-and-composites/src/test/java/com/webforj/tutorial/TutorialApp.java new file mode 100644 index 0000000..9d3ae51 --- /dev/null +++ b/3-routing-and-composites/src/test/java/com/webforj/tutorial/TutorialApp.java @@ -0,0 +1,159 @@ +package com.webforj.tutorial; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; + +final class TutorialApp implements AutoCloseable { + private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(3); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .build(); + + private final Process process; + private final String baseUrl; + private final Path logFile; + + private TutorialApp(Process process, String baseUrl, Path logFile) { + this.process = process; + this.baseUrl = baseUrl; + this.logFile = logFile; + } + + static TutorialApp start() throws Exception { + requireDocker(); + + Path stepPath = Path.of(System.getProperty("e2e.app.dir")).toAbsolutePath().normalize(); + Path jar = Path.of(System.getProperty("e2e.app.jar")).toAbsolutePath().normalize(); + if (!Files.isRegularFile(jar)) { + throw new IllegalStateException("Application JAR does not exist: " + jar + + ". Run the complete Maven verify lifecycle to package the application first."); + } + + int port = findFreePort(); + String baseUrl = "http://127.0.0.1:" + port; + Path logFile = Path.of(System.getProperty("e2e.output.dir"), "e2e-artifacts", "server.log"); + Files.createDirectories(logFile.getParent()); + + String javaExecutable = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + ProcessBuilder processBuilder = new ProcessBuilder( + javaExecutable, + "-jar", + jar.toString(), + "--server.port=" + port, + "--server.address=127.0.0.1", + "--spring.jpa.hibernate.ddl-auto=create-drop", + "--webforj.devtools.browser.open=false", + "--webforj.devtools.livereload.enabled=false", + "--webforj.devtools.livereload.static-resources-enabled=false", + "--webforj.devtools.craftforj.enabled=false", + "--webforj.debug=false"); + processBuilder.directory(stepPath.toFile()); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(logFile.toFile()); + + Process process = processBuilder.start(); + TutorialApp app = new TutorialApp(process, baseUrl, logFile); + try { + app.waitUntilReady(); + return app; + } catch (Exception exception) { + app.close(); + throw exception; + } + } + + String baseUrl() { + return baseUrl; + } + + static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException( + "E2E tests may only run in Docker. Run `mvn verify` from this tutorial step's directory."); + } + } + + private static int findFreePort() throws IOException { + for (int preferredPort : List.of(8080, 8090)) { + try (ServerSocket ignored = new ServerSocket(preferredPort)) { + return preferredPort; + } catch (IOException ignored) { + // Try the next preferred port. + } + } + + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void waitUntilReady() throws Exception { + long deadline = System.nanoTime() + STARTUP_TIMEOUT.toNanos(); + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/")) + .timeout(Duration.ofSeconds(3)) + .GET() + .build(); + + while (System.nanoTime() < deadline) { + if (!process.isAlive()) { + throw new IllegalStateException( + "Application exited before becoming ready.\n" + tailLog()); + } + + try { + HttpResponse response = HTTP_CLIENT.send( + request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() < 500) { + return; + } + } catch (IOException ignored) { + // The server is still starting. + } + + Thread.sleep(500); + } + + throw new IllegalStateException( + "Application did not become ready within " + STARTUP_TIMEOUT + ".\n" + tailLog()); + } + + private String tailLog() { + try { + List lines = Files.readAllLines(logFile, StandardCharsets.UTF_8); + int start = Math.max(0, lines.size() - 100); + return "Server log: " + logFile + "\n" + String.join("\n", lines.subList(start, lines.size())); + } catch (IOException exception) { + return "Could not read server log " + logFile + ": " + exception.getMessage(); + } + } + + @Override + public void close() { + if (!process.isAlive()) { + return; + } + + process.descendants().forEach(ProcessHandle::destroy); + process.destroy(); + try { + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } +} diff --git a/3-routing-and-composites/src/test/java/com/webforj/tutorial/VisualAssertions.java b/3-routing-and-composites/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..c42d6b9 --- /dev/null +++ b/3-routing-and-composites/src/test/java/com/webforj/tutorial/VisualAssertions.java @@ -0,0 +1,198 @@ +package com.webforj.tutorial; + +import static org.junit.jupiter.api.Assertions.fail; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import javax.imageio.ImageIO; + +final class VisualAssertions { + private static final int DEFAULT_MAX_DIFFERENT_PIXELS = 500; + private static final int CHANNEL_THRESHOLD = 51; + private static final int DEFAULT_TIMEOUT_MS = 10_000; + private static final int POLL_INTERVAL_MS = 250; + + /** + * Every {@code dwc-icon} resolves its artwork over the network and injects an {@code } into + * its own shadow root, so an unresolved icon is invisible in a screenshot while the surrounding + * DOM already looks complete. Icons live inside nested shadow roots, hence the manual walk. + */ + private static final String ICONS_RENDERED = """ + () => { + const icons = []; + const walk = (root) => { + for (const element of root.querySelectorAll('*')) { + if (element.localName === 'dwc-icon') { + icons.push(element); + } + if (element.shadowRoot) { + walk(element.shadowRoot); + } + } + }; + walk(document); + return icons.every(icon => icon.shadowRoot && icon.shadowRoot.querySelector('svg')); + } + """; + + private VisualAssertions() { + } + + static void assertScreenshot(Page page, String name) throws IOException { + requirePngName(name); + awaitRenderedIcons(page); + + Path baselineDirectory = Path.of(requiredProperty("visual.baseline.dir")); + Path artifactDirectory = Path.of(requiredProperty("visual.artifact.dir")); + Path baseline = baselineDirectory.resolve(name); + byte[] screenshot = captureStable(page, name, artifactDirectory); + + if (Boolean.getBoolean("updateScreenshots")) { + requireDocker(); + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing Docker screenshot baseline " + baseline + + ". Generate it with `mvn verify -DupdateScreenshots=true`."); + } + + BufferedImage expected = ImageIO.read(baseline.toFile()); + if (expected == null) { + fail("Could not decode screenshot baseline for " + name); + } + + BufferedImage actual = ImageIO.read(new ByteArrayInputStream(screenshot)); + if (actual == null) { + fail("Could not decode captured screenshot for " + name); + } + + if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { + writeFailureArtifacts(artifactDirectory, name, screenshot, null); + fail("Screenshot dimensions differ for " + name + ": expected " + + expected.getWidth() + "x" + expected.getHeight() + ", actual " + + actual.getWidth() + "x" + actual.getHeight()); + } + + BufferedImage diff = new BufferedImage( + actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); + int differentPixels = comparePixels(expected, actual, diff); + int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); + if (differentPixels > maximum) { + writeFailureArtifacts(artifactDirectory, name, screenshot, diff); + fail("Screenshot mismatch for " + name + ": " + differentPixels + + " pixels differ (maximum " + maximum + "). See " + artifactDirectory); + } + } + + private static void awaitRenderedIcons(Page page) { + try { + page.waitForFunction(ICONS_RENDERED, null, + new Page.WaitForFunctionOptions().setTimeout(timeoutMs())); + } catch (RuntimeException e) { + fail("Icons did not finish rendering within " + timeoutMs() + + " ms; screenshots would capture missing icons. Icon artwork is fetched from" + + " cdn.jsdelivr.net, so check network access from the container.", e); + } + } + + private static byte[] capture(Page page) { + page.evaluate("() => document.fonts.ready"); + return page.screenshot(new Page.ScreenshotOptions() + .setAnimations(ScreenshotAnimations.DISABLED) + .setCaret(ScreenshotCaret.HIDE) + .setScale(ScreenshotScale.CSS)); + } + + private static byte[] captureStable( + Page page, String name, Path artifactDirectory) throws IOException { + long deadline = System.nanoTime() + timeoutMs() * 1_000_000L; + byte[] previous = capture(page); + while (System.nanoTime() < deadline) { + page.waitForTimeout(POLL_INTERVAL_MS); + byte[] current = capture(page); + if (Arrays.equals(previous, current)) { + return current; + } + previous = current; + } + + writeFailureArtifacts(artifactDirectory, name, previous, null); + return fail("Page did not produce two consecutive identical screenshots for " + name + + " within " + timeoutMs() + " ms. Refusing to compare or update an unstable image. See " + + artifactDirectory); + } + + private static int comparePixels(BufferedImage expected, BufferedImage actual, BufferedImage diff) { + int differentPixels = 0; + for (int y = 0; y < actual.getHeight(); y++) { + for (int x = 0; x < actual.getWidth(); x++) { + int expectedRgb = expected.getRGB(x, y); + int actualRgb = actual.getRGB(x, y); + if (isDifferent(expectedRgb, actualRgb)) { + differentPixels++; + diff.setRGB(x, y, Color.MAGENTA.getRGB()); + } else { + Color pixel = new Color(actualRgb, true); + int gray = (pixel.getRed() + pixel.getGreen() + pixel.getBlue()) / 3; + diff.setRGB(x, y, new Color(gray, gray, gray, 110).getRGB()); + } + } + } + return differentPixels; + } + + private static int timeoutMs() { + return Integer.getInteger("visual.timeoutMs", DEFAULT_TIMEOUT_MS); + } + + private static boolean isDifferent(int expectedRgb, int actualRgb) { + Color expected = new Color(expectedRgb, true); + Color actual = new Color(actualRgb, true); + return Math.abs(expected.getRed() - actual.getRed()) > CHANNEL_THRESHOLD + || Math.abs(expected.getGreen() - actual.getGreen()) > CHANNEL_THRESHOLD + || Math.abs(expected.getBlue() - actual.getBlue()) > CHANNEL_THRESHOLD + || Math.abs(expected.getAlpha() - actual.getAlpha()) > CHANNEL_THRESHOLD; + } + + private static void writeFailureArtifacts( + Path artifactDirectory, String name, byte[] screenshot, BufferedImage diff) throws IOException { + Files.createDirectories(artifactDirectory); + String stem = name.substring(0, name.length() - ".png".length()); + Files.write(artifactDirectory.resolve(stem + "-actual.png"), screenshot); + if (diff != null) { + ImageIO.write(diff, "png", artifactDirectory.resolve(stem + "-diff.png").toFile()); + } + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required system property: " + name); + } + return value; + } + + private static void requirePngName(String name) { + if (!name.matches("[a-z0-9-]+\\.png")) { + throw new IllegalArgumentException("Screenshot name must be a simple kebab-case PNG filename: " + name); + } + } + + private static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException("Screenshot baselines may only be generated in Docker."); + } + } +} diff --git a/e2e/src/test/resources/screenshots/completed-customer-form.png b/3-routing-and-composites/src/test/resources/screenshots/completed-customer-form.png similarity index 100% rename from e2e/src/test/resources/screenshots/completed-customer-form.png rename to 3-routing-and-composites/src/test/resources/screenshots/completed-customer-form.png diff --git a/4-observers-and-route-parameters/README.md b/4-observers-and-route-parameters/README.md index 6b97b67..19d53c1 100644 --- a/4-observers-and-route-parameters/README.md +++ b/4-observers-and-route-parameters/README.md @@ -14,6 +14,7 @@ To run the app, ensure the following tools are installed: - Java 21 or higher - BBj 26.02 when running with local BBjServices - Maven +- Docker Desktop (or another Docker engine) for end-to-end tests - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser - Git (recommended) @@ -49,6 +50,24 @@ webforj-tutorial ``` 3. Open your browser and go to [http://localhost:8080](http://localhost:8080). +## End-to-End and Screenshot Tests + +With Docker running, execute this command from this step's directory: + +```sh +mvn verify +``` + +Maven launches the step's Java Playwright tests in the pinned Docker image. Only this step is built and tested. Tests are in `src/test/java/com/webforj/tutorial`, screenshot baselines in `src/test/resources/screenshots`, and reports, traces, and logs under `target`. + +To update this step's baselines after an intentional visual change: + +```sh +mvn verify -DupdateScreenshots=true +``` + +Both test execution and baseline generation happen in Docker. `mvn test` runs unit tests; the end-to-end tests run during `mvn verify`. + ## Project Highlights - **Spring Boot integration:** Autowire Spring beans directly into webforJ views and components. diff --git a/4-observers-and-route-parameters/pom.xml b/4-observers-and-route-parameters/pom.xml index d2883aa..690d928 100644 --- a/4-observers-and-route-parameters/pom.xml +++ b/4-observers-and-route-parameters/pom.xml @@ -18,6 +18,13 @@ 21 21 UTF-8 + 1.50.0 + 5.11.4 + *IT + false + false + ${maven.test.skip} + ${skipTests} @@ -58,6 +65,17 @@ com.h2database h2 + + com.microsoft.playwright + playwright + ${playwright.version} + test + + + org.junit.jupiter + junit-jupiter + test + compile webforj:watch spring-boot:run @@ -99,5 +117,99 @@ true + + + docker-e2e + + + !docker.e2e.container + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + docker-e2e + verify + exec + + ${skipITs} + docker + + run + --rm + --init + --ipc=host + --workdir=/app + -e + CI=true + -e + E2E_IN_DOCKER=true + --mount=type=bind,source=${project.basedir},target=/app + -v + webforj-tutorial-e2e-maven-repo:/var/maven/.m2 + -e + MAVEN_CONFIG=/var/maven/.m2 + mcr.microsoft.com/playwright/java:v${playwright.version}-noble + mvn + -B + -ntp + -Dmaven.repo.local=/var/maven/.m2/repository + -Ddocker.e2e.container=true + -DupdateScreenshots=${updateScreenshots} + -Dit.test=${it.test} + clean + verify + + + + + + + + + + + container-e2e + + + docker.e2e.container + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + + integration-test + verify + + + + + true + false + false + + ${project.basedir} + ${project.build.directory}/${project.build.finalName}.jar + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + + + + + + diff --git a/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/BaseTest.java b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/BaseTest.java new file mode 100644 index 0000000..f34110d --- /dev/null +++ b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,155 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.ConsoleMessage; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.Request; +import com.microsoft.playwright.Tracing; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.AriaRole; +import com.microsoft.playwright.options.ColorScheme; +import com.microsoft.playwright.options.WaitUntilState; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class BaseTest { + private Playwright playwright; + private Browser browser; + private BrowserContext context; + private TutorialApp application; + private final List browserErrors = new ArrayList<>(); + + protected Page page; + + @BeforeAll + void startBrowser() { + TutorialApp.requireDocker(); + playwright = Playwright.create(); + browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + PlaywrightAssertions.setDefaultAssertionTimeout(10_000); + } + + @BeforeEach + void startApplicationAndCreateBrowserContext() throws Exception { + browserErrors.clear(); + application = TutorialApp.start(); + context = browser.newContext(new Browser.NewContextOptions() + .setViewportSize(1440, 900) + .setDeviceScaleFactor(1) + .setColorScheme(ColorScheme.LIGHT) + .setLocale("en-US") + .setTimezoneId("UTC") + .setIgnoreHTTPSErrors(true)); + context.tracing().start(new Tracing.StartOptions() + .setScreenshots(true) + .setSnapshots(true) + .setSources(true)); + + page = context.newPage(); + page.onPageError(error -> browserErrors.add("pageerror: " + error)); + page.onConsoleMessage(this::recordConsoleError); + page.onRequestFailed(this::recordRequestFailure); + } + + @AfterEach + void closeBrowserContext(TestInfo testInfo) throws IOException { + List errorsBeforeClose = List.copyOf(browserErrors); + try { + if (context != null) { + Path traceDirectory = Path.of(System.getProperty("e2e.output.dir"), "playwright-traces"); + Files.createDirectories(traceDirectory); + String testName = (getClass().getSimpleName() + "-" + testInfo.getDisplayName()) + .replaceAll("[^a-zA-Z0-9.-]", "-"); + context.tracing().stop(new Tracing.StopOptions() + .setPath(traceDirectory.resolve(testName + ".zip"))); + context.close(); + context = null; + } + } finally { + if (application != null) { + application.close(); + application = null; + } + } + assertTrue(errorsBeforeClose.isEmpty(), + () -> "Browser runtime/network errors:\n" + String.join("\n", errorsBeforeClose)); + } + + @AfterAll + void stopBrowser() { + if (browser != null) { + browser.close(); + } + if (playwright != null) { + playwright.close(); + } + } + + protected void openApplication() { + openApplication("/"); + } + + protected void openApplication(String path) { + String normalizedPath = path.startsWith("/") ? path : "/" + path; + page.navigate(application.baseUrl() + normalizedPath, + new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + } + + protected Locator customerTable() { + return page.locator("dwc-table"); + } + + protected Locator expectCustomerTable() { + Locator table = customerTable(); + assertThat(table).isVisible(); + assertThat(table).containsText("First Name"); + assertThat(table).containsText("Last Name"); + assertThat(table).containsText("Company"); + assertThat(table).containsText("Country"); + assertThat(table).containsText("Alice"); + assertThat(table).containsText("TechCorp"); + return table; + } + + protected Locator expectCustomerRow(String identifyingCellText) { + Locator identifyingCell = page.getByRole(AriaRole.CELL, + new Page.GetByRoleOptions().setName(identifyingCellText).setExact(true)); + Locator row = page.getByRole(AriaRole.ROW) + .filter(new Locator.FilterOptions().setHas(identifyingCell)); + assertThat(row).hasCount(1); + return row; + } + + private void recordConsoleError(ConsoleMessage message) { + if ("error".equals(message.type())) { + browserErrors.add("console: " + message.text()); + } + } + + private void recordRequestFailure(Request request) { + String failure = request.failure(); + boolean cancelledWebforjPoll = "net::ERR_ABORTED".equals(failure) + && request.url().contains("/webforjServlet/webapprmi"); + if (!cancelledWebforjPoll) { + browserErrors.add( + "requestfailed: " + request.method() + " " + request.url() + " (" + failure + ")"); + } + } +} diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step4IT.java b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/Step4IT.java similarity index 92% rename from e2e/src/test/java/com/webforj/tutorial/e2e/Step4IT.java rename to 4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/Step4IT.java index 43ee235..90eebc4 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/Step4IT.java +++ b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/Step4IT.java @@ -1,4 +1,4 @@ -package com.webforj.tutorial.e2e; +package com.webforj.tutorial; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; @@ -9,11 +9,6 @@ import org.junit.jupiter.api.Test; class Step4IT extends BaseTest { - @Override - protected String stepDirectory() { - return "4-observers-and-route-parameters"; - } - @Test void showsRoutedCustomerFormConsistently() throws IOException { openApplication("/customer/1"); diff --git a/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/TutorialApp.java b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/TutorialApp.java new file mode 100644 index 0000000..9d3ae51 --- /dev/null +++ b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/TutorialApp.java @@ -0,0 +1,159 @@ +package com.webforj.tutorial; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; + +final class TutorialApp implements AutoCloseable { + private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(3); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .build(); + + private final Process process; + private final String baseUrl; + private final Path logFile; + + private TutorialApp(Process process, String baseUrl, Path logFile) { + this.process = process; + this.baseUrl = baseUrl; + this.logFile = logFile; + } + + static TutorialApp start() throws Exception { + requireDocker(); + + Path stepPath = Path.of(System.getProperty("e2e.app.dir")).toAbsolutePath().normalize(); + Path jar = Path.of(System.getProperty("e2e.app.jar")).toAbsolutePath().normalize(); + if (!Files.isRegularFile(jar)) { + throw new IllegalStateException("Application JAR does not exist: " + jar + + ". Run the complete Maven verify lifecycle to package the application first."); + } + + int port = findFreePort(); + String baseUrl = "http://127.0.0.1:" + port; + Path logFile = Path.of(System.getProperty("e2e.output.dir"), "e2e-artifacts", "server.log"); + Files.createDirectories(logFile.getParent()); + + String javaExecutable = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + ProcessBuilder processBuilder = new ProcessBuilder( + javaExecutable, + "-jar", + jar.toString(), + "--server.port=" + port, + "--server.address=127.0.0.1", + "--spring.jpa.hibernate.ddl-auto=create-drop", + "--webforj.devtools.browser.open=false", + "--webforj.devtools.livereload.enabled=false", + "--webforj.devtools.livereload.static-resources-enabled=false", + "--webforj.devtools.craftforj.enabled=false", + "--webforj.debug=false"); + processBuilder.directory(stepPath.toFile()); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(logFile.toFile()); + + Process process = processBuilder.start(); + TutorialApp app = new TutorialApp(process, baseUrl, logFile); + try { + app.waitUntilReady(); + return app; + } catch (Exception exception) { + app.close(); + throw exception; + } + } + + String baseUrl() { + return baseUrl; + } + + static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException( + "E2E tests may only run in Docker. Run `mvn verify` from this tutorial step's directory."); + } + } + + private static int findFreePort() throws IOException { + for (int preferredPort : List.of(8080, 8090)) { + try (ServerSocket ignored = new ServerSocket(preferredPort)) { + return preferredPort; + } catch (IOException ignored) { + // Try the next preferred port. + } + } + + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void waitUntilReady() throws Exception { + long deadline = System.nanoTime() + STARTUP_TIMEOUT.toNanos(); + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/")) + .timeout(Duration.ofSeconds(3)) + .GET() + .build(); + + while (System.nanoTime() < deadline) { + if (!process.isAlive()) { + throw new IllegalStateException( + "Application exited before becoming ready.\n" + tailLog()); + } + + try { + HttpResponse response = HTTP_CLIENT.send( + request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() < 500) { + return; + } + } catch (IOException ignored) { + // The server is still starting. + } + + Thread.sleep(500); + } + + throw new IllegalStateException( + "Application did not become ready within " + STARTUP_TIMEOUT + ".\n" + tailLog()); + } + + private String tailLog() { + try { + List lines = Files.readAllLines(logFile, StandardCharsets.UTF_8); + int start = Math.max(0, lines.size() - 100); + return "Server log: " + logFile + "\n" + String.join("\n", lines.subList(start, lines.size())); + } catch (IOException exception) { + return "Could not read server log " + logFile + ": " + exception.getMessage(); + } + } + + @Override + public void close() { + if (!process.isAlive()) { + return; + } + + process.descendants().forEach(ProcessHandle::destroy); + process.destroy(); + try { + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } +} diff --git a/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/VisualAssertions.java b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..c42d6b9 --- /dev/null +++ b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/VisualAssertions.java @@ -0,0 +1,198 @@ +package com.webforj.tutorial; + +import static org.junit.jupiter.api.Assertions.fail; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import javax.imageio.ImageIO; + +final class VisualAssertions { + private static final int DEFAULT_MAX_DIFFERENT_PIXELS = 500; + private static final int CHANNEL_THRESHOLD = 51; + private static final int DEFAULT_TIMEOUT_MS = 10_000; + private static final int POLL_INTERVAL_MS = 250; + + /** + * Every {@code dwc-icon} resolves its artwork over the network and injects an {@code } into + * its own shadow root, so an unresolved icon is invisible in a screenshot while the surrounding + * DOM already looks complete. Icons live inside nested shadow roots, hence the manual walk. + */ + private static final String ICONS_RENDERED = """ + () => { + const icons = []; + const walk = (root) => { + for (const element of root.querySelectorAll('*')) { + if (element.localName === 'dwc-icon') { + icons.push(element); + } + if (element.shadowRoot) { + walk(element.shadowRoot); + } + } + }; + walk(document); + return icons.every(icon => icon.shadowRoot && icon.shadowRoot.querySelector('svg')); + } + """; + + private VisualAssertions() { + } + + static void assertScreenshot(Page page, String name) throws IOException { + requirePngName(name); + awaitRenderedIcons(page); + + Path baselineDirectory = Path.of(requiredProperty("visual.baseline.dir")); + Path artifactDirectory = Path.of(requiredProperty("visual.artifact.dir")); + Path baseline = baselineDirectory.resolve(name); + byte[] screenshot = captureStable(page, name, artifactDirectory); + + if (Boolean.getBoolean("updateScreenshots")) { + requireDocker(); + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing Docker screenshot baseline " + baseline + + ". Generate it with `mvn verify -DupdateScreenshots=true`."); + } + + BufferedImage expected = ImageIO.read(baseline.toFile()); + if (expected == null) { + fail("Could not decode screenshot baseline for " + name); + } + + BufferedImage actual = ImageIO.read(new ByteArrayInputStream(screenshot)); + if (actual == null) { + fail("Could not decode captured screenshot for " + name); + } + + if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { + writeFailureArtifacts(artifactDirectory, name, screenshot, null); + fail("Screenshot dimensions differ for " + name + ": expected " + + expected.getWidth() + "x" + expected.getHeight() + ", actual " + + actual.getWidth() + "x" + actual.getHeight()); + } + + BufferedImage diff = new BufferedImage( + actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); + int differentPixels = comparePixels(expected, actual, diff); + int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); + if (differentPixels > maximum) { + writeFailureArtifacts(artifactDirectory, name, screenshot, diff); + fail("Screenshot mismatch for " + name + ": " + differentPixels + + " pixels differ (maximum " + maximum + "). See " + artifactDirectory); + } + } + + private static void awaitRenderedIcons(Page page) { + try { + page.waitForFunction(ICONS_RENDERED, null, + new Page.WaitForFunctionOptions().setTimeout(timeoutMs())); + } catch (RuntimeException e) { + fail("Icons did not finish rendering within " + timeoutMs() + + " ms; screenshots would capture missing icons. Icon artwork is fetched from" + + " cdn.jsdelivr.net, so check network access from the container.", e); + } + } + + private static byte[] capture(Page page) { + page.evaluate("() => document.fonts.ready"); + return page.screenshot(new Page.ScreenshotOptions() + .setAnimations(ScreenshotAnimations.DISABLED) + .setCaret(ScreenshotCaret.HIDE) + .setScale(ScreenshotScale.CSS)); + } + + private static byte[] captureStable( + Page page, String name, Path artifactDirectory) throws IOException { + long deadline = System.nanoTime() + timeoutMs() * 1_000_000L; + byte[] previous = capture(page); + while (System.nanoTime() < deadline) { + page.waitForTimeout(POLL_INTERVAL_MS); + byte[] current = capture(page); + if (Arrays.equals(previous, current)) { + return current; + } + previous = current; + } + + writeFailureArtifacts(artifactDirectory, name, previous, null); + return fail("Page did not produce two consecutive identical screenshots for " + name + + " within " + timeoutMs() + " ms. Refusing to compare or update an unstable image. See " + + artifactDirectory); + } + + private static int comparePixels(BufferedImage expected, BufferedImage actual, BufferedImage diff) { + int differentPixels = 0; + for (int y = 0; y < actual.getHeight(); y++) { + for (int x = 0; x < actual.getWidth(); x++) { + int expectedRgb = expected.getRGB(x, y); + int actualRgb = actual.getRGB(x, y); + if (isDifferent(expectedRgb, actualRgb)) { + differentPixels++; + diff.setRGB(x, y, Color.MAGENTA.getRGB()); + } else { + Color pixel = new Color(actualRgb, true); + int gray = (pixel.getRed() + pixel.getGreen() + pixel.getBlue()) / 3; + diff.setRGB(x, y, new Color(gray, gray, gray, 110).getRGB()); + } + } + } + return differentPixels; + } + + private static int timeoutMs() { + return Integer.getInteger("visual.timeoutMs", DEFAULT_TIMEOUT_MS); + } + + private static boolean isDifferent(int expectedRgb, int actualRgb) { + Color expected = new Color(expectedRgb, true); + Color actual = new Color(actualRgb, true); + return Math.abs(expected.getRed() - actual.getRed()) > CHANNEL_THRESHOLD + || Math.abs(expected.getGreen() - actual.getGreen()) > CHANNEL_THRESHOLD + || Math.abs(expected.getBlue() - actual.getBlue()) > CHANNEL_THRESHOLD + || Math.abs(expected.getAlpha() - actual.getAlpha()) > CHANNEL_THRESHOLD; + } + + private static void writeFailureArtifacts( + Path artifactDirectory, String name, byte[] screenshot, BufferedImage diff) throws IOException { + Files.createDirectories(artifactDirectory); + String stem = name.substring(0, name.length() - ".png".length()); + Files.write(artifactDirectory.resolve(stem + "-actual.png"), screenshot); + if (diff != null) { + ImageIO.write(diff, "png", artifactDirectory.resolve(stem + "-diff.png").toFile()); + } + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required system property: " + name); + } + return value; + } + + private static void requirePngName(String name) { + if (!name.matches("[a-z0-9-]+\\.png")) { + throw new IllegalArgumentException("Screenshot name must be a simple kebab-case PNG filename: " + name); + } + } + + private static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException("Screenshot baselines may only be generated in Docker."); + } + } +} diff --git a/e2e/src/test/resources/screenshots/edit-customer-route.png b/4-observers-and-route-parameters/src/test/resources/screenshots/edit-customer-route.png similarity index 100% rename from e2e/src/test/resources/screenshots/edit-customer-route.png rename to 4-observers-and-route-parameters/src/test/resources/screenshots/edit-customer-route.png diff --git a/5-validating-and-binding-data/README.md b/5-validating-and-binding-data/README.md index 6b97b67..19d53c1 100644 --- a/5-validating-and-binding-data/README.md +++ b/5-validating-and-binding-data/README.md @@ -14,6 +14,7 @@ To run the app, ensure the following tools are installed: - Java 21 or higher - BBj 26.02 when running with local BBjServices - Maven +- Docker Desktop (or another Docker engine) for end-to-end tests - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser - Git (recommended) @@ -49,6 +50,24 @@ webforj-tutorial ``` 3. Open your browser and go to [http://localhost:8080](http://localhost:8080). +## End-to-End and Screenshot Tests + +With Docker running, execute this command from this step's directory: + +```sh +mvn verify +``` + +Maven launches the step's Java Playwright tests in the pinned Docker image. Only this step is built and tested. Tests are in `src/test/java/com/webforj/tutorial`, screenshot baselines in `src/test/resources/screenshots`, and reports, traces, and logs under `target`. + +To update this step's baselines after an intentional visual change: + +```sh +mvn verify -DupdateScreenshots=true +``` + +Both test execution and baseline generation happen in Docker. `mvn test` runs unit tests; the end-to-end tests run during `mvn verify`. + ## Project Highlights - **Spring Boot integration:** Autowire Spring beans directly into webforJ views and components. diff --git a/5-validating-and-binding-data/pom.xml b/5-validating-and-binding-data/pom.xml index d570ab6..342d94e 100644 --- a/5-validating-and-binding-data/pom.xml +++ b/5-validating-and-binding-data/pom.xml @@ -18,6 +18,13 @@ 21 21 UTF-8 + 1.50.0 + 5.11.4 + *IT + false + false + ${maven.test.skip} + ${skipTests} @@ -58,6 +65,17 @@ com.h2database h2 + + com.microsoft.playwright + playwright + ${playwright.version} + test + + + org.junit.jupiter + junit-jupiter + test + compile webforj:watch spring-boot:run @@ -99,5 +117,99 @@ true + + + docker-e2e + + + !docker.e2e.container + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + docker-e2e + verify + exec + + ${skipITs} + docker + + run + --rm + --init + --ipc=host + --workdir=/app + -e + CI=true + -e + E2E_IN_DOCKER=true + --mount=type=bind,source=${project.basedir},target=/app + -v + webforj-tutorial-e2e-maven-repo:/var/maven/.m2 + -e + MAVEN_CONFIG=/var/maven/.m2 + mcr.microsoft.com/playwright/java:v${playwright.version}-noble + mvn + -B + -ntp + -Dmaven.repo.local=/var/maven/.m2/repository + -Ddocker.e2e.container=true + -DupdateScreenshots=${updateScreenshots} + -Dit.test=${it.test} + clean + verify + + + + + + + + + + + container-e2e + + + docker.e2e.container + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + + integration-test + verify + + + + + true + false + false + + ${project.basedir} + ${project.build.directory}/${project.build.finalName}.jar + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + + + + + + diff --git a/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/BaseTest.java b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/BaseTest.java new file mode 100644 index 0000000..f34110d --- /dev/null +++ b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,155 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.ConsoleMessage; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.Request; +import com.microsoft.playwright.Tracing; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.AriaRole; +import com.microsoft.playwright.options.ColorScheme; +import com.microsoft.playwright.options.WaitUntilState; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class BaseTest { + private Playwright playwright; + private Browser browser; + private BrowserContext context; + private TutorialApp application; + private final List browserErrors = new ArrayList<>(); + + protected Page page; + + @BeforeAll + void startBrowser() { + TutorialApp.requireDocker(); + playwright = Playwright.create(); + browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + PlaywrightAssertions.setDefaultAssertionTimeout(10_000); + } + + @BeforeEach + void startApplicationAndCreateBrowserContext() throws Exception { + browserErrors.clear(); + application = TutorialApp.start(); + context = browser.newContext(new Browser.NewContextOptions() + .setViewportSize(1440, 900) + .setDeviceScaleFactor(1) + .setColorScheme(ColorScheme.LIGHT) + .setLocale("en-US") + .setTimezoneId("UTC") + .setIgnoreHTTPSErrors(true)); + context.tracing().start(new Tracing.StartOptions() + .setScreenshots(true) + .setSnapshots(true) + .setSources(true)); + + page = context.newPage(); + page.onPageError(error -> browserErrors.add("pageerror: " + error)); + page.onConsoleMessage(this::recordConsoleError); + page.onRequestFailed(this::recordRequestFailure); + } + + @AfterEach + void closeBrowserContext(TestInfo testInfo) throws IOException { + List errorsBeforeClose = List.copyOf(browserErrors); + try { + if (context != null) { + Path traceDirectory = Path.of(System.getProperty("e2e.output.dir"), "playwright-traces"); + Files.createDirectories(traceDirectory); + String testName = (getClass().getSimpleName() + "-" + testInfo.getDisplayName()) + .replaceAll("[^a-zA-Z0-9.-]", "-"); + context.tracing().stop(new Tracing.StopOptions() + .setPath(traceDirectory.resolve(testName + ".zip"))); + context.close(); + context = null; + } + } finally { + if (application != null) { + application.close(); + application = null; + } + } + assertTrue(errorsBeforeClose.isEmpty(), + () -> "Browser runtime/network errors:\n" + String.join("\n", errorsBeforeClose)); + } + + @AfterAll + void stopBrowser() { + if (browser != null) { + browser.close(); + } + if (playwright != null) { + playwright.close(); + } + } + + protected void openApplication() { + openApplication("/"); + } + + protected void openApplication(String path) { + String normalizedPath = path.startsWith("/") ? path : "/" + path; + page.navigate(application.baseUrl() + normalizedPath, + new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + } + + protected Locator customerTable() { + return page.locator("dwc-table"); + } + + protected Locator expectCustomerTable() { + Locator table = customerTable(); + assertThat(table).isVisible(); + assertThat(table).containsText("First Name"); + assertThat(table).containsText("Last Name"); + assertThat(table).containsText("Company"); + assertThat(table).containsText("Country"); + assertThat(table).containsText("Alice"); + assertThat(table).containsText("TechCorp"); + return table; + } + + protected Locator expectCustomerRow(String identifyingCellText) { + Locator identifyingCell = page.getByRole(AriaRole.CELL, + new Page.GetByRoleOptions().setName(identifyingCellText).setExact(true)); + Locator row = page.getByRole(AriaRole.ROW) + .filter(new Locator.FilterOptions().setHas(identifyingCell)); + assertThat(row).hasCount(1); + return row; + } + + private void recordConsoleError(ConsoleMessage message) { + if ("error".equals(message.type())) { + browserErrors.add("console: " + message.text()); + } + } + + private void recordRequestFailure(Request request) { + String failure = request.failure(); + boolean cancelledWebforjPoll = "net::ERR_ABORTED".equals(failure) + && request.url().contains("/webforjServlet/webapprmi"); + if (!cancelledWebforjPoll) { + browserErrors.add( + "requestfailed: " + request.method() + " " + request.url() + " (" + failure + ")"); + } + } +} diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step5IT.java b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/Step5IT.java similarity index 92% rename from e2e/src/test/java/com/webforj/tutorial/e2e/Step5IT.java rename to 5-validating-and-binding-data/src/test/java/com/webforj/tutorial/Step5IT.java index 9f3c6a6..2da4beb 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/Step5IT.java +++ b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/Step5IT.java @@ -1,4 +1,4 @@ -package com.webforj.tutorial.e2e; +package com.webforj.tutorial; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; @@ -9,11 +9,6 @@ import org.junit.jupiter.api.Test; class Step5IT extends BaseTest { - @Override - protected String stepDirectory() { - return "5-validating-and-binding-data"; - } - @Test void bindsAndValidatesCustomerBeforeSubmission() throws IOException { openApplication(); diff --git a/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/TutorialApp.java b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/TutorialApp.java new file mode 100644 index 0000000..9d3ae51 --- /dev/null +++ b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/TutorialApp.java @@ -0,0 +1,159 @@ +package com.webforj.tutorial; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; + +final class TutorialApp implements AutoCloseable { + private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(3); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .build(); + + private final Process process; + private final String baseUrl; + private final Path logFile; + + private TutorialApp(Process process, String baseUrl, Path logFile) { + this.process = process; + this.baseUrl = baseUrl; + this.logFile = logFile; + } + + static TutorialApp start() throws Exception { + requireDocker(); + + Path stepPath = Path.of(System.getProperty("e2e.app.dir")).toAbsolutePath().normalize(); + Path jar = Path.of(System.getProperty("e2e.app.jar")).toAbsolutePath().normalize(); + if (!Files.isRegularFile(jar)) { + throw new IllegalStateException("Application JAR does not exist: " + jar + + ". Run the complete Maven verify lifecycle to package the application first."); + } + + int port = findFreePort(); + String baseUrl = "http://127.0.0.1:" + port; + Path logFile = Path.of(System.getProperty("e2e.output.dir"), "e2e-artifacts", "server.log"); + Files.createDirectories(logFile.getParent()); + + String javaExecutable = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + ProcessBuilder processBuilder = new ProcessBuilder( + javaExecutable, + "-jar", + jar.toString(), + "--server.port=" + port, + "--server.address=127.0.0.1", + "--spring.jpa.hibernate.ddl-auto=create-drop", + "--webforj.devtools.browser.open=false", + "--webforj.devtools.livereload.enabled=false", + "--webforj.devtools.livereload.static-resources-enabled=false", + "--webforj.devtools.craftforj.enabled=false", + "--webforj.debug=false"); + processBuilder.directory(stepPath.toFile()); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(logFile.toFile()); + + Process process = processBuilder.start(); + TutorialApp app = new TutorialApp(process, baseUrl, logFile); + try { + app.waitUntilReady(); + return app; + } catch (Exception exception) { + app.close(); + throw exception; + } + } + + String baseUrl() { + return baseUrl; + } + + static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException( + "E2E tests may only run in Docker. Run `mvn verify` from this tutorial step's directory."); + } + } + + private static int findFreePort() throws IOException { + for (int preferredPort : List.of(8080, 8090)) { + try (ServerSocket ignored = new ServerSocket(preferredPort)) { + return preferredPort; + } catch (IOException ignored) { + // Try the next preferred port. + } + } + + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void waitUntilReady() throws Exception { + long deadline = System.nanoTime() + STARTUP_TIMEOUT.toNanos(); + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/")) + .timeout(Duration.ofSeconds(3)) + .GET() + .build(); + + while (System.nanoTime() < deadline) { + if (!process.isAlive()) { + throw new IllegalStateException( + "Application exited before becoming ready.\n" + tailLog()); + } + + try { + HttpResponse response = HTTP_CLIENT.send( + request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() < 500) { + return; + } + } catch (IOException ignored) { + // The server is still starting. + } + + Thread.sleep(500); + } + + throw new IllegalStateException( + "Application did not become ready within " + STARTUP_TIMEOUT + ".\n" + tailLog()); + } + + private String tailLog() { + try { + List lines = Files.readAllLines(logFile, StandardCharsets.UTF_8); + int start = Math.max(0, lines.size() - 100); + return "Server log: " + logFile + "\n" + String.join("\n", lines.subList(start, lines.size())); + } catch (IOException exception) { + return "Could not read server log " + logFile + ": " + exception.getMessage(); + } + } + + @Override + public void close() { + if (!process.isAlive()) { + return; + } + + process.descendants().forEach(ProcessHandle::destroy); + process.destroy(); + try { + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } +} diff --git a/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/VisualAssertions.java b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..c42d6b9 --- /dev/null +++ b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/VisualAssertions.java @@ -0,0 +1,198 @@ +package com.webforj.tutorial; + +import static org.junit.jupiter.api.Assertions.fail; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import javax.imageio.ImageIO; + +final class VisualAssertions { + private static final int DEFAULT_MAX_DIFFERENT_PIXELS = 500; + private static final int CHANNEL_THRESHOLD = 51; + private static final int DEFAULT_TIMEOUT_MS = 10_000; + private static final int POLL_INTERVAL_MS = 250; + + /** + * Every {@code dwc-icon} resolves its artwork over the network and injects an {@code } into + * its own shadow root, so an unresolved icon is invisible in a screenshot while the surrounding + * DOM already looks complete. Icons live inside nested shadow roots, hence the manual walk. + */ + private static final String ICONS_RENDERED = """ + () => { + const icons = []; + const walk = (root) => { + for (const element of root.querySelectorAll('*')) { + if (element.localName === 'dwc-icon') { + icons.push(element); + } + if (element.shadowRoot) { + walk(element.shadowRoot); + } + } + }; + walk(document); + return icons.every(icon => icon.shadowRoot && icon.shadowRoot.querySelector('svg')); + } + """; + + private VisualAssertions() { + } + + static void assertScreenshot(Page page, String name) throws IOException { + requirePngName(name); + awaitRenderedIcons(page); + + Path baselineDirectory = Path.of(requiredProperty("visual.baseline.dir")); + Path artifactDirectory = Path.of(requiredProperty("visual.artifact.dir")); + Path baseline = baselineDirectory.resolve(name); + byte[] screenshot = captureStable(page, name, artifactDirectory); + + if (Boolean.getBoolean("updateScreenshots")) { + requireDocker(); + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing Docker screenshot baseline " + baseline + + ". Generate it with `mvn verify -DupdateScreenshots=true`."); + } + + BufferedImage expected = ImageIO.read(baseline.toFile()); + if (expected == null) { + fail("Could not decode screenshot baseline for " + name); + } + + BufferedImage actual = ImageIO.read(new ByteArrayInputStream(screenshot)); + if (actual == null) { + fail("Could not decode captured screenshot for " + name); + } + + if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { + writeFailureArtifacts(artifactDirectory, name, screenshot, null); + fail("Screenshot dimensions differ for " + name + ": expected " + + expected.getWidth() + "x" + expected.getHeight() + ", actual " + + actual.getWidth() + "x" + actual.getHeight()); + } + + BufferedImage diff = new BufferedImage( + actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); + int differentPixels = comparePixels(expected, actual, diff); + int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); + if (differentPixels > maximum) { + writeFailureArtifacts(artifactDirectory, name, screenshot, diff); + fail("Screenshot mismatch for " + name + ": " + differentPixels + + " pixels differ (maximum " + maximum + "). See " + artifactDirectory); + } + } + + private static void awaitRenderedIcons(Page page) { + try { + page.waitForFunction(ICONS_RENDERED, null, + new Page.WaitForFunctionOptions().setTimeout(timeoutMs())); + } catch (RuntimeException e) { + fail("Icons did not finish rendering within " + timeoutMs() + + " ms; screenshots would capture missing icons. Icon artwork is fetched from" + + " cdn.jsdelivr.net, so check network access from the container.", e); + } + } + + private static byte[] capture(Page page) { + page.evaluate("() => document.fonts.ready"); + return page.screenshot(new Page.ScreenshotOptions() + .setAnimations(ScreenshotAnimations.DISABLED) + .setCaret(ScreenshotCaret.HIDE) + .setScale(ScreenshotScale.CSS)); + } + + private static byte[] captureStable( + Page page, String name, Path artifactDirectory) throws IOException { + long deadline = System.nanoTime() + timeoutMs() * 1_000_000L; + byte[] previous = capture(page); + while (System.nanoTime() < deadline) { + page.waitForTimeout(POLL_INTERVAL_MS); + byte[] current = capture(page); + if (Arrays.equals(previous, current)) { + return current; + } + previous = current; + } + + writeFailureArtifacts(artifactDirectory, name, previous, null); + return fail("Page did not produce two consecutive identical screenshots for " + name + + " within " + timeoutMs() + " ms. Refusing to compare or update an unstable image. See " + + artifactDirectory); + } + + private static int comparePixels(BufferedImage expected, BufferedImage actual, BufferedImage diff) { + int differentPixels = 0; + for (int y = 0; y < actual.getHeight(); y++) { + for (int x = 0; x < actual.getWidth(); x++) { + int expectedRgb = expected.getRGB(x, y); + int actualRgb = actual.getRGB(x, y); + if (isDifferent(expectedRgb, actualRgb)) { + differentPixels++; + diff.setRGB(x, y, Color.MAGENTA.getRGB()); + } else { + Color pixel = new Color(actualRgb, true); + int gray = (pixel.getRed() + pixel.getGreen() + pixel.getBlue()) / 3; + diff.setRGB(x, y, new Color(gray, gray, gray, 110).getRGB()); + } + } + } + return differentPixels; + } + + private static int timeoutMs() { + return Integer.getInteger("visual.timeoutMs", DEFAULT_TIMEOUT_MS); + } + + private static boolean isDifferent(int expectedRgb, int actualRgb) { + Color expected = new Color(expectedRgb, true); + Color actual = new Color(actualRgb, true); + return Math.abs(expected.getRed() - actual.getRed()) > CHANNEL_THRESHOLD + || Math.abs(expected.getGreen() - actual.getGreen()) > CHANNEL_THRESHOLD + || Math.abs(expected.getBlue() - actual.getBlue()) > CHANNEL_THRESHOLD + || Math.abs(expected.getAlpha() - actual.getAlpha()) > CHANNEL_THRESHOLD; + } + + private static void writeFailureArtifacts( + Path artifactDirectory, String name, byte[] screenshot, BufferedImage diff) throws IOException { + Files.createDirectories(artifactDirectory); + String stem = name.substring(0, name.length() - ".png".length()); + Files.write(artifactDirectory.resolve(stem + "-actual.png"), screenshot); + if (diff != null) { + ImageIO.write(diff, "png", artifactDirectory.resolve(stem + "-diff.png").toFile()); + } + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required system property: " + name); + } + return value; + } + + private static void requirePngName(String name) { + if (!name.matches("[a-z0-9-]+\\.png")) { + throw new IllegalArgumentException("Screenshot name must be a simple kebab-case PNG filename: " + name); + } + } + + private static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException("Screenshot baselines may only be generated in Docker."); + } + } +} diff --git a/e2e/src/test/resources/screenshots/valid-customer-form.png b/5-validating-and-binding-data/src/test/resources/screenshots/valid-customer-form.png similarity index 100% rename from e2e/src/test/resources/screenshots/valid-customer-form.png rename to 5-validating-and-binding-data/src/test/resources/screenshots/valid-customer-form.png diff --git a/6-integrating-an-app-layout/README.md b/6-integrating-an-app-layout/README.md index 6b97b67..19d53c1 100644 --- a/6-integrating-an-app-layout/README.md +++ b/6-integrating-an-app-layout/README.md @@ -14,6 +14,7 @@ To run the app, ensure the following tools are installed: - Java 21 or higher - BBj 26.02 when running with local BBjServices - Maven +- Docker Desktop (or another Docker engine) for end-to-end tests - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser - Git (recommended) @@ -49,6 +50,24 @@ webforj-tutorial ``` 3. Open your browser and go to [http://localhost:8080](http://localhost:8080). +## End-to-End and Screenshot Tests + +With Docker running, execute this command from this step's directory: + +```sh +mvn verify +``` + +Maven launches the step's Java Playwright tests in the pinned Docker image. Only this step is built and tested. Tests are in `src/test/java/com/webforj/tutorial`, screenshot baselines in `src/test/resources/screenshots`, and reports, traces, and logs under `target`. + +To update this step's baselines after an intentional visual change: + +```sh +mvn verify -DupdateScreenshots=true +``` + +Both test execution and baseline generation happen in Docker. `mvn test` runs unit tests; the end-to-end tests run during `mvn verify`. + ## Project Highlights - **Spring Boot integration:** Autowire Spring beans directly into webforJ views and components. diff --git a/6-integrating-an-app-layout/pom.xml b/6-integrating-an-app-layout/pom.xml index 07112a7..6272d72 100644 --- a/6-integrating-an-app-layout/pom.xml +++ b/6-integrating-an-app-layout/pom.xml @@ -18,6 +18,13 @@ 21 21 UTF-8 + 1.50.0 + 5.11.4 + *IT + false + false + ${maven.test.skip} + ${skipTests} @@ -58,6 +65,17 @@ com.h2database h2 + + com.microsoft.playwright + playwright + ${playwright.version} + test + + + org.junit.jupiter + junit-jupiter + test + compile webforj:watch spring-boot:run @@ -99,5 +117,99 @@ true + + + docker-e2e + + + !docker.e2e.container + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + docker-e2e + verify + exec + + ${skipITs} + docker + + run + --rm + --init + --ipc=host + --workdir=/app + -e + CI=true + -e + E2E_IN_DOCKER=true + --mount=type=bind,source=${project.basedir},target=/app + -v + webforj-tutorial-e2e-maven-repo:/var/maven/.m2 + -e + MAVEN_CONFIG=/var/maven/.m2 + mcr.microsoft.com/playwright/java:v${playwright.version}-noble + mvn + -B + -ntp + -Dmaven.repo.local=/var/maven/.m2/repository + -Ddocker.e2e.container=true + -DupdateScreenshots=${updateScreenshots} + -Dit.test=${it.test} + clean + verify + + + + + + + + + + + container-e2e + + + docker.e2e.container + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + + integration-test + verify + + + + + true + false + false + + ${project.basedir} + ${project.build.directory}/${project.build.finalName}.jar + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + + + + + + diff --git a/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/BaseTest.java b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/BaseTest.java new file mode 100644 index 0000000..f34110d --- /dev/null +++ b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,155 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.ConsoleMessage; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.Request; +import com.microsoft.playwright.Tracing; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.AriaRole; +import com.microsoft.playwright.options.ColorScheme; +import com.microsoft.playwright.options.WaitUntilState; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class BaseTest { + private Playwright playwright; + private Browser browser; + private BrowserContext context; + private TutorialApp application; + private final List browserErrors = new ArrayList<>(); + + protected Page page; + + @BeforeAll + void startBrowser() { + TutorialApp.requireDocker(); + playwright = Playwright.create(); + browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + PlaywrightAssertions.setDefaultAssertionTimeout(10_000); + } + + @BeforeEach + void startApplicationAndCreateBrowserContext() throws Exception { + browserErrors.clear(); + application = TutorialApp.start(); + context = browser.newContext(new Browser.NewContextOptions() + .setViewportSize(1440, 900) + .setDeviceScaleFactor(1) + .setColorScheme(ColorScheme.LIGHT) + .setLocale("en-US") + .setTimezoneId("UTC") + .setIgnoreHTTPSErrors(true)); + context.tracing().start(new Tracing.StartOptions() + .setScreenshots(true) + .setSnapshots(true) + .setSources(true)); + + page = context.newPage(); + page.onPageError(error -> browserErrors.add("pageerror: " + error)); + page.onConsoleMessage(this::recordConsoleError); + page.onRequestFailed(this::recordRequestFailure); + } + + @AfterEach + void closeBrowserContext(TestInfo testInfo) throws IOException { + List errorsBeforeClose = List.copyOf(browserErrors); + try { + if (context != null) { + Path traceDirectory = Path.of(System.getProperty("e2e.output.dir"), "playwright-traces"); + Files.createDirectories(traceDirectory); + String testName = (getClass().getSimpleName() + "-" + testInfo.getDisplayName()) + .replaceAll("[^a-zA-Z0-9.-]", "-"); + context.tracing().stop(new Tracing.StopOptions() + .setPath(traceDirectory.resolve(testName + ".zip"))); + context.close(); + context = null; + } + } finally { + if (application != null) { + application.close(); + application = null; + } + } + assertTrue(errorsBeforeClose.isEmpty(), + () -> "Browser runtime/network errors:\n" + String.join("\n", errorsBeforeClose)); + } + + @AfterAll + void stopBrowser() { + if (browser != null) { + browser.close(); + } + if (playwright != null) { + playwright.close(); + } + } + + protected void openApplication() { + openApplication("/"); + } + + protected void openApplication(String path) { + String normalizedPath = path.startsWith("/") ? path : "/" + path; + page.navigate(application.baseUrl() + normalizedPath, + new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + } + + protected Locator customerTable() { + return page.locator("dwc-table"); + } + + protected Locator expectCustomerTable() { + Locator table = customerTable(); + assertThat(table).isVisible(); + assertThat(table).containsText("First Name"); + assertThat(table).containsText("Last Name"); + assertThat(table).containsText("Company"); + assertThat(table).containsText("Country"); + assertThat(table).containsText("Alice"); + assertThat(table).containsText("TechCorp"); + return table; + } + + protected Locator expectCustomerRow(String identifyingCellText) { + Locator identifyingCell = page.getByRole(AriaRole.CELL, + new Page.GetByRoleOptions().setName(identifyingCellText).setExact(true)); + Locator row = page.getByRole(AriaRole.ROW) + .filter(new Locator.FilterOptions().setHas(identifyingCell)); + assertThat(row).hasCount(1); + return row; + } + + private void recordConsoleError(ConsoleMessage message) { + if ("error".equals(message.type())) { + browserErrors.add("console: " + message.text()); + } + } + + private void recordRequestFailure(Request request) { + String failure = request.failure(); + boolean cancelledWebforjPoll = "net::ERR_ABORTED".equals(failure) + && request.url().contains("/webforjServlet/webapprmi"); + if (!cancelledWebforjPoll) { + browserErrors.add( + "requestfailed: " + request.method() + " " + request.url() + " (" + failure + ")"); + } + } +} diff --git a/e2e/src/test/java/com/webforj/tutorial/e2e/Step6IT.java b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/Step6IT.java similarity index 95% rename from e2e/src/test/java/com/webforj/tutorial/e2e/Step6IT.java rename to 6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/Step6IT.java index 8fac7f9..1a3bd18 100644 --- a/e2e/src/test/java/com/webforj/tutorial/e2e/Step6IT.java +++ b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/Step6IT.java @@ -1,4 +1,4 @@ -package com.webforj.tutorial.e2e; +package com.webforj.tutorial; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; @@ -10,11 +10,6 @@ import org.junit.jupiter.api.Test; class Step6IT extends BaseTest { - @Override - protected String stepDirectory() { - return "6-integrating-an-app-layout"; - } - @Test void providesApplicationLayoutNavigation() throws IOException { openApplication(); diff --git a/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/TutorialApp.java b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/TutorialApp.java new file mode 100644 index 0000000..9d3ae51 --- /dev/null +++ b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/TutorialApp.java @@ -0,0 +1,159 @@ +package com.webforj.tutorial; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; + +final class TutorialApp implements AutoCloseable { + private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(3); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .build(); + + private final Process process; + private final String baseUrl; + private final Path logFile; + + private TutorialApp(Process process, String baseUrl, Path logFile) { + this.process = process; + this.baseUrl = baseUrl; + this.logFile = logFile; + } + + static TutorialApp start() throws Exception { + requireDocker(); + + Path stepPath = Path.of(System.getProperty("e2e.app.dir")).toAbsolutePath().normalize(); + Path jar = Path.of(System.getProperty("e2e.app.jar")).toAbsolutePath().normalize(); + if (!Files.isRegularFile(jar)) { + throw new IllegalStateException("Application JAR does not exist: " + jar + + ". Run the complete Maven verify lifecycle to package the application first."); + } + + int port = findFreePort(); + String baseUrl = "http://127.0.0.1:" + port; + Path logFile = Path.of(System.getProperty("e2e.output.dir"), "e2e-artifacts", "server.log"); + Files.createDirectories(logFile.getParent()); + + String javaExecutable = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + ProcessBuilder processBuilder = new ProcessBuilder( + javaExecutable, + "-jar", + jar.toString(), + "--server.port=" + port, + "--server.address=127.0.0.1", + "--spring.jpa.hibernate.ddl-auto=create-drop", + "--webforj.devtools.browser.open=false", + "--webforj.devtools.livereload.enabled=false", + "--webforj.devtools.livereload.static-resources-enabled=false", + "--webforj.devtools.craftforj.enabled=false", + "--webforj.debug=false"); + processBuilder.directory(stepPath.toFile()); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(logFile.toFile()); + + Process process = processBuilder.start(); + TutorialApp app = new TutorialApp(process, baseUrl, logFile); + try { + app.waitUntilReady(); + return app; + } catch (Exception exception) { + app.close(); + throw exception; + } + } + + String baseUrl() { + return baseUrl; + } + + static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException( + "E2E tests may only run in Docker. Run `mvn verify` from this tutorial step's directory."); + } + } + + private static int findFreePort() throws IOException { + for (int preferredPort : List.of(8080, 8090)) { + try (ServerSocket ignored = new ServerSocket(preferredPort)) { + return preferredPort; + } catch (IOException ignored) { + // Try the next preferred port. + } + } + + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void waitUntilReady() throws Exception { + long deadline = System.nanoTime() + STARTUP_TIMEOUT.toNanos(); + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/")) + .timeout(Duration.ofSeconds(3)) + .GET() + .build(); + + while (System.nanoTime() < deadline) { + if (!process.isAlive()) { + throw new IllegalStateException( + "Application exited before becoming ready.\n" + tailLog()); + } + + try { + HttpResponse response = HTTP_CLIENT.send( + request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() < 500) { + return; + } + } catch (IOException ignored) { + // The server is still starting. + } + + Thread.sleep(500); + } + + throw new IllegalStateException( + "Application did not become ready within " + STARTUP_TIMEOUT + ".\n" + tailLog()); + } + + private String tailLog() { + try { + List lines = Files.readAllLines(logFile, StandardCharsets.UTF_8); + int start = Math.max(0, lines.size() - 100); + return "Server log: " + logFile + "\n" + String.join("\n", lines.subList(start, lines.size())); + } catch (IOException exception) { + return "Could not read server log " + logFile + ": " + exception.getMessage(); + } + } + + @Override + public void close() { + if (!process.isAlive()) { + return; + } + + process.descendants().forEach(ProcessHandle::destroy); + process.destroy(); + try { + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } +} diff --git a/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/VisualAssertions.java b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..c42d6b9 --- /dev/null +++ b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/VisualAssertions.java @@ -0,0 +1,198 @@ +package com.webforj.tutorial; + +import static org.junit.jupiter.api.Assertions.fail; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import javax.imageio.ImageIO; + +final class VisualAssertions { + private static final int DEFAULT_MAX_DIFFERENT_PIXELS = 500; + private static final int CHANNEL_THRESHOLD = 51; + private static final int DEFAULT_TIMEOUT_MS = 10_000; + private static final int POLL_INTERVAL_MS = 250; + + /** + * Every {@code dwc-icon} resolves its artwork over the network and injects an {@code } into + * its own shadow root, so an unresolved icon is invisible in a screenshot while the surrounding + * DOM already looks complete. Icons live inside nested shadow roots, hence the manual walk. + */ + private static final String ICONS_RENDERED = """ + () => { + const icons = []; + const walk = (root) => { + for (const element of root.querySelectorAll('*')) { + if (element.localName === 'dwc-icon') { + icons.push(element); + } + if (element.shadowRoot) { + walk(element.shadowRoot); + } + } + }; + walk(document); + return icons.every(icon => icon.shadowRoot && icon.shadowRoot.querySelector('svg')); + } + """; + + private VisualAssertions() { + } + + static void assertScreenshot(Page page, String name) throws IOException { + requirePngName(name); + awaitRenderedIcons(page); + + Path baselineDirectory = Path.of(requiredProperty("visual.baseline.dir")); + Path artifactDirectory = Path.of(requiredProperty("visual.artifact.dir")); + Path baseline = baselineDirectory.resolve(name); + byte[] screenshot = captureStable(page, name, artifactDirectory); + + if (Boolean.getBoolean("updateScreenshots")) { + requireDocker(); + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing Docker screenshot baseline " + baseline + + ". Generate it with `mvn verify -DupdateScreenshots=true`."); + } + + BufferedImage expected = ImageIO.read(baseline.toFile()); + if (expected == null) { + fail("Could not decode screenshot baseline for " + name); + } + + BufferedImage actual = ImageIO.read(new ByteArrayInputStream(screenshot)); + if (actual == null) { + fail("Could not decode captured screenshot for " + name); + } + + if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { + writeFailureArtifacts(artifactDirectory, name, screenshot, null); + fail("Screenshot dimensions differ for " + name + ": expected " + + expected.getWidth() + "x" + expected.getHeight() + ", actual " + + actual.getWidth() + "x" + actual.getHeight()); + } + + BufferedImage diff = new BufferedImage( + actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); + int differentPixels = comparePixels(expected, actual, diff); + int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); + if (differentPixels > maximum) { + writeFailureArtifacts(artifactDirectory, name, screenshot, diff); + fail("Screenshot mismatch for " + name + ": " + differentPixels + + " pixels differ (maximum " + maximum + "). See " + artifactDirectory); + } + } + + private static void awaitRenderedIcons(Page page) { + try { + page.waitForFunction(ICONS_RENDERED, null, + new Page.WaitForFunctionOptions().setTimeout(timeoutMs())); + } catch (RuntimeException e) { + fail("Icons did not finish rendering within " + timeoutMs() + + " ms; screenshots would capture missing icons. Icon artwork is fetched from" + + " cdn.jsdelivr.net, so check network access from the container.", e); + } + } + + private static byte[] capture(Page page) { + page.evaluate("() => document.fonts.ready"); + return page.screenshot(new Page.ScreenshotOptions() + .setAnimations(ScreenshotAnimations.DISABLED) + .setCaret(ScreenshotCaret.HIDE) + .setScale(ScreenshotScale.CSS)); + } + + private static byte[] captureStable( + Page page, String name, Path artifactDirectory) throws IOException { + long deadline = System.nanoTime() + timeoutMs() * 1_000_000L; + byte[] previous = capture(page); + while (System.nanoTime() < deadline) { + page.waitForTimeout(POLL_INTERVAL_MS); + byte[] current = capture(page); + if (Arrays.equals(previous, current)) { + return current; + } + previous = current; + } + + writeFailureArtifacts(artifactDirectory, name, previous, null); + return fail("Page did not produce two consecutive identical screenshots for " + name + + " within " + timeoutMs() + " ms. Refusing to compare or update an unstable image. See " + + artifactDirectory); + } + + private static int comparePixels(BufferedImage expected, BufferedImage actual, BufferedImage diff) { + int differentPixels = 0; + for (int y = 0; y < actual.getHeight(); y++) { + for (int x = 0; x < actual.getWidth(); x++) { + int expectedRgb = expected.getRGB(x, y); + int actualRgb = actual.getRGB(x, y); + if (isDifferent(expectedRgb, actualRgb)) { + differentPixels++; + diff.setRGB(x, y, Color.MAGENTA.getRGB()); + } else { + Color pixel = new Color(actualRgb, true); + int gray = (pixel.getRed() + pixel.getGreen() + pixel.getBlue()) / 3; + diff.setRGB(x, y, new Color(gray, gray, gray, 110).getRGB()); + } + } + } + return differentPixels; + } + + private static int timeoutMs() { + return Integer.getInteger("visual.timeoutMs", DEFAULT_TIMEOUT_MS); + } + + private static boolean isDifferent(int expectedRgb, int actualRgb) { + Color expected = new Color(expectedRgb, true); + Color actual = new Color(actualRgb, true); + return Math.abs(expected.getRed() - actual.getRed()) > CHANNEL_THRESHOLD + || Math.abs(expected.getGreen() - actual.getGreen()) > CHANNEL_THRESHOLD + || Math.abs(expected.getBlue() - actual.getBlue()) > CHANNEL_THRESHOLD + || Math.abs(expected.getAlpha() - actual.getAlpha()) > CHANNEL_THRESHOLD; + } + + private static void writeFailureArtifacts( + Path artifactDirectory, String name, byte[] screenshot, BufferedImage diff) throws IOException { + Files.createDirectories(artifactDirectory); + String stem = name.substring(0, name.length() - ".png".length()); + Files.write(artifactDirectory.resolve(stem + "-actual.png"), screenshot); + if (diff != null) { + ImageIO.write(diff, "png", artifactDirectory.resolve(stem + "-diff.png").toFile()); + } + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required system property: " + name); + } + return value; + } + + private static void requirePngName(String name) { + if (!name.matches("[a-z0-9-]+\\.png")) { + throw new IllegalArgumentException("Screenshot name must be a simple kebab-case PNG filename: " + name); + } + } + + private static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException("Screenshot baselines may only be generated in Docker."); + } + } +} diff --git a/e2e/src/test/resources/screenshots/dashboard-layout.png b/6-integrating-an-app-layout/src/test/resources/screenshots/dashboard-layout.png similarity index 100% rename from e2e/src/test/resources/screenshots/dashboard-layout.png rename to 6-integrating-an-app-layout/src/test/resources/screenshots/dashboard-layout.png diff --git a/e2e/src/test/resources/screenshots/dashboard-mobile.png b/6-integrating-an-app-layout/src/test/resources/screenshots/dashboard-mobile.png similarity index 100% rename from e2e/src/test/resources/screenshots/dashboard-mobile.png rename to 6-integrating-an-app-layout/src/test/resources/screenshots/dashboard-mobile.png diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index e32a4eb..0000000 --- a/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -# Keep this image tag exactly aligned with the Playwright Java dependency in pom.xml. -FROM mcr.microsoft.com/playwright/java:v1.50.0-noble - -WORKDIR /app -COPY . /app - -ENV CI=true -ENV E2E_IN_DOCKER=true -ENV MAVEN_CONFIG=/var/maven/.m2 -RUN mkdir -p /var/maven/.m2 - -CMD ["mvn", "-ntp", "-Ddocker.e2e.container=true", "verify"] diff --git a/README.md b/README.md index a283bb2..f5df2d7 100644 --- a/README.md +++ b/README.md @@ -52,36 +52,42 @@ webforj-tutorial ## End-to-End and Screenshot Tests -The root Maven project runs the Java Playwright suite exclusively in Docker. The container packages and starts each tutorial step separately, uses Chromium from the pinned official Playwright Java image, and stops the application after that step's tests. Applications prefer port 8080, fall back to 8090, and otherwise select a free container port. +Each tutorial step owns its Java Playwright tests in `src/test/java/com/webforj/tutorial`, mirroring the application's Java package, and its screenshot baselines in `src/test/resources/screenshots`. Test helpers are included in each step so the step remains self-contained, with no dependency on a sibling project. -Run all six tutorial steps, including screenshot comparisons: +With Docker running, enter the step you want to test and run the normal Maven verification lifecycle: ```sh +cd 3-routing-and-composites mvn verify ``` -Run an individual step in Docker: +This builds and tests only that step. Maven launches the pinned official Playwright Java Docker image with the step directory mounted at `/app`. Inside the container, Maven performs a clean build and runs the Java Playwright tests and screenshot comparisons with Failsafe. No root POM or custom Docker image is needed. + +To select a test class or method within the current step: ```sh -mvn verify -Dit.test=Step3IT +mvn verify "-Dit.test=Step3IT#createsCustomerAndReturnsToTable" ``` -After an intentional visual change, regenerate every committed baseline in the same Docker environment: +After an intentional visual change, regenerate this step's baselines in the same Docker environment: ```sh mvn verify -DupdateScreenshots=true ``` -You can also use the documentation-style explicit Docker goals. Build the image once, then run or update tests with the existing image: +GitHub Actions runs `scripts/run-e2e.sh`, which enters each step and invokes `mvn -B -ntp verify`. It continues after a failed step and exits unsuccessfully if any step failed. You can run the same script from the repository root using Bash (Git Bash on Windows): ```sh -mvn exec:exec@docker-e2e-build -mvn exec:exec@docker-e2e -mvn exec:exec@docker-e2e-single -Dtest=Step3IT -mvn exec:exec@docker-e2e-update +bash scripts/run-e2e.sh ``` -Screenshot baselines are stored in `e2e/src/test/resources/screenshots`. The comparator allows up to 500 anti-aliased pixels to differ in each 1440×900 capture; failure images are written to `target/visual-diffs`, Playwright traces to `target/playwright-traces`, and application logs to `target/e2e-artifacts`. The inner test profile also rejects execution unless `E2E_IN_DOCKER=true`, preventing accidental host-generated baselines. +The workflow's optional `step` input selects one step; scheduled runs test all steps. + +In each step, test reports are written to `target/failsafe-reports`, failure images to `target/visual-diffs`, Playwright traces to `target/playwright-traces`, and application logs to `target/e2e-artifacts`. The screenshot comparator allows up to 500 pixels beyond its per-channel tolerance to differ. + +Each test starts a fresh application and browser context and stops them afterward. Applications prefer port 8080, fall back to 8090, and otherwise select a free container port. `mvn test` runs unit tests, while `mvn verify` also runs the E2E tests in Docker. Standard `-DskipITs`, `-DskipTests`, and `-Dmaven.test.skip=true` flags skip the Docker run when requested. + +The `container-e2e` profile binds Failsafe only for the inner Docker run, and the test setup rejects execution unless `E2E_IN_DOCKER=true` before launching Playwright. Baseline generation uses this same guard. Each step's `playwright.version` selects both the Java dependency and the Docker image tag. ## Project Highlights diff --git a/pom.xml b/pom.xml deleted file mode 100644 index 3b6bd01..0000000 --- a/pom.xml +++ /dev/null @@ -1,323 +0,0 @@ - - - 4.0.0 - - com.webforj.tutorial - tutorial-e2e - 1.0-SNAPSHOT - webforJ Tutorial End-to-End Tests - - - UTF-8 - 21 - 1.50.0 - 5.11.4 - webforj-tutorial-e2e:playwright-${playwright.version} - *IT - false - - - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.2.5 - - true - - - - - - - - - docker-e2e - - - !docker.e2e.container - - - - - - org.codehaus.mojo - exec-maven-plugin - 3.1.0 - - - docker-e2e-build - pre-integration-test - - exec - - - docker - - build - -t - ${docker.image} - . - - - - - docker-e2e - integration-test - - exec - - - docker - - run - --rm - --init - --ipc=host - -e - CI=true - -e - E2E_IN_DOCKER=true - --mount=type=bind,source=${project.basedir},target=/app - -v - webforj-tutorial-e2e-maven-repo:/var/maven/.m2 - -e - MAVEN_CONFIG=/var/maven/.m2 - ${docker.image} - mvn - -ntp - -Dmaven.repo.local=/var/maven/.m2/repository - -Ddocker.e2e.container=true - -DupdateScreenshots=${updateScreenshots} - -Dit.test=${it.test} - verify - - - - - docker-e2e-single - - exec - - - docker - - run - --rm - --init - --ipc=host - -e - CI=true - -e - E2E_IN_DOCKER=true - --mount=type=bind,source=${project.basedir},target=/app - -v - webforj-tutorial-e2e-maven-repo:/var/maven/.m2 - -e - MAVEN_CONFIG=/var/maven/.m2 - ${docker.image} - mvn - -ntp - -Dmaven.repo.local=/var/maven/.m2/repository - -Ddocker.e2e.container=true - -DupdateScreenshots=false - -Dit.test=${test} - verify - - - - - docker-e2e-update - - exec - - - docker - - run - --rm - --init - --ipc=host - -e - CI=true - -e - E2E_IN_DOCKER=true - --mount=type=bind,source=${project.basedir},target=/app - -v - webforj-tutorial-e2e-maven-repo:/var/maven/.m2 - -e - MAVEN_CONFIG=/var/maven/.m2 - ${docker.image} - mvn - -ntp - -Dmaven.repo.local=/var/maven/.m2/repository - -Ddocker.e2e.container=true - -DupdateScreenshots=true - -Dit.test=${it.test} - verify - - - - - - - - - - - - container-e2e - - - docker.e2e.container - true - - - - - com.microsoft.playwright - playwright - ${playwright.version} - test - - - org.junit.jupiter - junit-jupiter-api - ${junit.version} - test - - - org.junit.jupiter - junit-jupiter-engine - ${junit.version} - test - - - - - - org.codehaus.mojo - build-helper-maven-plugin - 3.6.0 - - - add-e2e-test-source - generate-test-sources - add-test-source - - - ${project.basedir}/e2e/src/test/java - - - - - add-e2e-test-resources - generate-test-resources - add-test-resource - - - - ${project.basedir}/e2e/src/test/resources - - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.13.0 - - - org.codehaus.mojo - exec-maven-plugin - 3.1.0 - - - build-step-1 - pre-integration-test - exec - - mvn - -ntp-f${project.basedir}/1-creating-a-basic-app/pom.xml-DskipTestspackage - - - - build-step-2 - pre-integration-test - exec - - mvn - -ntp-f${project.basedir}/2-working-with-data/pom.xml-DskipTestspackage - - - - build-step-3 - pre-integration-test - exec - - mvn - -ntp-f${project.basedir}/3-routing-and-composites/pom.xml-DskipTestspackage - - - - build-step-4 - pre-integration-test - exec - - mvn - -ntp-f${project.basedir}/4-observers-and-route-parameters/pom.xml-DskipTestspackage - - - - build-step-5 - pre-integration-test - exec - - mvn - -ntp-f${project.basedir}/5-validating-and-binding-data/pom.xml-DskipTestspackage - - - - build-step-6 - pre-integration-test - exec - - mvn - -ntp-f${project.basedir}/6-integrating-an-app-layout/pom.xml-DskipTestspackage - - - - - - org.apache.maven.plugins - maven-failsafe-plugin - 3.2.5 - - - - integration-test - verify - - - - - 1 - true - false - false - - ${project.basedir}/e2e/src/test/resources/screenshots - ${project.build.directory}/visual-diffs - - - - - - - - diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh new file mode 100644 index 0000000..5843593 --- /dev/null +++ b/scripts/run-e2e.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +requested_step="${1:-all}" + +if (( $# > 1 )) || [[ ! "$requested_step" =~ ^(all|[1-9][0-9]*)$ ]]; then + printf 'Usage: bash scripts/run-e2e.sh [all|step-number]\n' >&2 + exit 2 +fi + +shopt -s nullglob +step_poms=("$repo_root"/[0-9]*-*/pom.xml) +selected_steps=() +failed_steps=() + +for pom in "${step_poms[@]}"; do + step_dir="$(dirname -- "$pom")" + step="$(basename -- "$step_dir")" + if [[ "$requested_step" != all && "$step" != "$requested_step"-* ]]; then + continue + fi + + selected_steps+=("$step") + printf '\nRunning E2E tests in %s\n' "$step" + if (cd -- "$step_dir" && mvn -B -ntp verify); then + printf 'PASS: %s\n' "$step" + else + failed_steps+=("$step") + printf 'FAIL: %s\n' "$step" >&2 + fi +done + +if (( ${#selected_steps[@]} == 0 )); then + printf 'No tutorial step found for: %s\n' "$requested_step" >&2 + exit 2 +fi + +if (( ${#failed_steps[@]} > 0 )); then + printf '\nFailed tutorial steps:\n' >&2 + printf ' %s\n' "${failed_steps[@]}" >&2 + exit 1 +fi + +printf '\nAll %s selected tutorial steps passed.\n' "${#selected_steps[@]}"