diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 008524b..cbf3b4c 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -66,6 +66,7 @@ jobs:
**/target/surefire-reports/
**/target/failsafe-reports/
**/target/jsp-engine/
+ jakarta-test/target/packaged-war.log
esapi-compatibility:
name: ESAPI ${{ matrix.esapi-version }}
diff --git a/jakarta-test/README.md b/jakarta-test/README.md
new file mode 100644
index 0000000..4fb3317
--- /dev/null
+++ b/jakarta-test/README.md
@@ -0,0 +1,88 @@
+# Required browser and packaged WAR fixture
+
+This optional application is a test fixture, not a dependency of an encoder
+library. From the repository root, with JDK 17, Maven and Docker available:
+
+```sh
+mvn -B -ntp -Dmaven.repo.local=/tmp/encoder-browser-m2 clean verify -PtestJakarta
+```
+
+Use an empty task-specific Maven directory for fresh validation. The reactor
+packages the matching encoder JARs without installing them. CI requires this
+profile in the `Java CI gate`; unavailable Docker is a failure, not a skipped or
+advisory test. CI also compares the JAR inside the WAR byte-for-byte with the
+reactor's Jakarta adapter.
+
+## Coverage decision (#93)
+
+Retain the browser fixture. The Docker-free [Jasper tests](../compatibility/jsp-engine/README.md)
+cover every packaged basic/advanced tag and EL binding, coercions, output bytes,
+and invalid JSP translation. They cannot replace these browser assertions:
+
+- `ItemControllerTest`: JSP/JSTL startup, tag and EL output interpreted as text in
+ actual DOM cells, no injected script elements, standards-mode HTML.
+- `JavaScriptTemplateTest`: all four JavaScript encoders through quoted strings
+ and ordinary template literals, interpolation boundaries, HTML script and
+ event-attribute parsing, controls and lone surrogates through UTF-8, and the
+ explicitly unsupported raw-template round-trip behavior.
+- `PackagedWarIT`: launches `java -jar` on the finished executable WAR on a
+ random loopback port, renders both packaged views, checks exact encoded cell
+ content, and confirms JSTL API/implementation and adapter JARs are packaged.
+ It terminates the server even on failure. This test needs no Docker.
+
+Browser sessions and containers are explicitly closed in `AfterAll` with
+`finally` cleanup. Video recording is disabled, so no unused recorder image is
+started. Surefire/Failsafe output and `target/packaged-war.log` are retained by CI.
+A local Chrome-only diagnostic for the JavaScript suite remains available with
+`-Dencoder.browser.local=true -Dtest=JavaScriptTemplateTest`; it is not the CI gate.
+
+## Framework and API boundaries
+
+As reviewed on 2026-09-25, this fixture uses supported Spring Boot **4.1.1** and
+its managed dependencies, on JDK **17**, with Tomcat/Jasper **11.0.26**
+(Servlet **6.1**, Pages **4.0**, EL **6.0**). The two deliberate BOM overrides are
+Tomcat 11.0.26, which contains the September fixes absent from Boot's managed
+11.0.24, and Selenium 4.49.0, aligned with the current reviewed browser image.
+Testcontainers **2.0.5**, JSTL API **3.0.2** and implementation **3.0.1** follow the
+Boot BOM. The standalone Servlet/Pages/EL API JARs are removed; Tomcat supplies
+the coherent implementation/API set. Both JSTL components remain packaged.
+The unused JSON starter, empty test configuration/launcher, and unused service
+mutation scaffold are removed.
+
+See the [Boot support policy](https://github.com/spring-projects/spring-boot/wiki/Supported-Versions),
+[system requirements](https://docs.spring.io/spring-boot/system-requirements.html),
+[Spring advisories](https://spring.io/security/), and
+[Tomcat 11 advisories](https://tomcat.apache.org/security-11.html).
+An OSV query of the 34 resolved third-party JAR coordinates in the packaged WAR
+(including provided container libraries) returned no advisories on 2026-09-25.
+That dated result excludes container OS packages, build plugins and test-only
+JARs; it is not a permanent or repository-wide clean bill.
+Recheck these sources and the resolved dependency graph with each upgrade and
+before release. Do not copy these fixture requirements into library support claims.
+The published adapters retain Java 8 and their existing provided APIs. The
+separate javax/Jakarta engines and Java 8/11/17/21/25 packaged consumers still
+exercise older contracts. OSGi's conservative Pages import range is unchanged;
+this Boot application is not an OSGi container test.
+
+## Container provenance and updates
+
+`BrowserFixture.java` and test-only `testcontainers.properties` contain immutable
+multi-platform index digests fetched from Docker Hub's registry and verified
+against the SHA-256 of each manifest response on 2026-09-25:
+
+| Use | Reviewed tag | Index SHA-256 |
+| --- | --- | --- |
+| Browser | `selenium/standalone-chrome:4.49.0-20260909` | `7efe71e7e4a83bdf574b26bd354690928075e8f443223d2ced16a2c208eae1d7` |
+| Cleanup | `testcontainers/ryuk:0.14.0` | `7c1a8a9a47c780ed0f983770a662f80deb115d95cce3e2daa3d12115b8cd28f0` |
+| Host-port forwarding | `testcontainers/sshd:1.3.0` | `c50c0f59554dcdb2d9e5e705112144428ae9d04ac0af6322b365a18e24213a6a` |
+| Docker startup probe | `alpine:3.24.2` | `294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6` |
+
+The [Selenium release](https://github.com/SeleniumHQ/docker-selenium/releases/tag/4.49.0-20260909)
+and [Testcontainers 2.0.5 source](https://github.com/testcontainers/testcontainers-java/tree/2.0.5)
+control the browser/helper choices. The startup probe uses maintained Alpine
+instead of the old default 3.17. Digest pins provide immutable identity, not a
+claim that an image contains no vulnerabilities. Review publisher release notes,
+image scan results and all helper versions when updating. Keep the Selenium
+client and image aligned, update the tag and digest together, verify the manifest
+hash/platforms again, and run the full required profile before merging. These
+source/property pins require manual review; Maven Dependabot does not update them.
diff --git a/jakarta-test/pom.xml b/jakarta-test/pom.xml
index 37d4111..a64daf7 100644
--- a/jakarta-test/pom.xml
+++ b/jakarta-test/pom.xml
@@ -5,7 +5,7 @@
org.springframework.boot
spring-boot-starter-parent
- 3.5.16
+ 4.1.1
org.owasp.encoder.testing
@@ -16,6 +16,10 @@
Test for OWASP encoder jakarta JSP
17
+
+ 11.0.26
+
+ 4.49.0
1.5.0-SNAPSHOT
@@ -28,7 +32,14 @@
org.springframework.boot
- spring-boot-starter-web
+ spring-boot-starter-webmvc
+
+
+
+ org.springframework.boot
+ spring-boot-starter-jackson
+
+
org.apache.tomcat.embed
@@ -40,25 +51,14 @@
spring-boot-starter-tomcat
provided
-
- jakarta.servlet
- jakarta.servlet-api
- provided
-
-
- jakarta.servlet.jsp
- jakarta.servlet.jsp-api
- 3.1.0
- provided
-
jakarta.servlet.jsp.jstl
jakarta.servlet.jsp.jstl-api
-
-
- jakarta.el
- jakarta.el-api
- 5.0.1
+
+
+ jakarta.eljakarta.el-api
+ jakarta.servletjakarta.servlet-api
+
org.glassfish.web
@@ -70,14 +70,9 @@
spring-boot-starter-test
test
-
- org.springframework.boot
- spring-boot-testcontainers
- test
-
org.testcontainers
- selenium
+ testcontainers-selenium
test
@@ -90,16 +85,25 @@
selenium-chrome-driver
test
-
- org.testcontainers
- junit-jupiter
- test
-
jakarta-test
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+
+
+ ${project.build.directory}/${project.build.finalName}.war
+
+
+
+
+ integration-testverify
+
+
+
org.springframework.boot
spring-boot-maven-plugin
diff --git a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/controller/ItemController.java b/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/controller/ItemController.java
index 3b22a6f..7824cde 100644
--- a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/controller/ItemController.java
+++ b/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/controller/ItemController.java
@@ -1,6 +1,7 @@
package org.owasp.encoder.testing.jakarta_test.controller;
-import org.owasp.encoder.testing.jakarta_test.service.ItemService;
+import java.util.List;
+import org.owasp.encoder.testing.jakarta_test.dto.Item;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -14,15 +15,13 @@
@RequestMapping("/item")
public class ItemController {
- private final ItemService itemService;
-
- public ItemController(ItemService itemService) {
- this.itemService = itemService;
- }
+ private static final List- ITEMS = List.of(
+ new Item(1, "menu", "blob"),
+ new Item(2, "top", "fancy "));
@GetMapping("/viewItems")
public String viewItems(Model model) {
- model.addAttribute("items", itemService.getItems());
+ model.addAttribute("items", ITEMS);
return "view-items";
}
}
diff --git a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/dto/Item.java b/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/dto/Item.java
index 4cda55c..0c1c72b 100644
--- a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/dto/Item.java
+++ b/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/dto/Item.java
@@ -1,19 +1,10 @@
package org.owasp.encoder.testing.jakarta_test.dto;
-/**
- *
- * @author jeremy
- */
-public class Item {
-
- private int id;
-
- private String name;
-
- private String description;
-
- public Item() {
- }
+/** Immutable values exposed as bean properties to JSP EL. */
+public final class Item {
+ private final int id;
+ private final String name;
+ private final String description;
public Item(int id, String name, String description) {
this.id = id;
@@ -21,57 +12,7 @@ public Item(int id, String name, String description) {
this.description = description;
}
- /**
- * Get the value of id
- *
- * @return the value of id
- */
- public int getId() {
- return id;
- }
-
- /**
- * Set the value of id
- *
- * @param id new value of id
- */
- public void setId(int id) {
- this.id = id;
- }
-
- /**
- * Get the value of name
- *
- * @return the value of name
- */
- public String getName() {
- return name;
- }
-
- /**
- * Set the value of name
- *
- * @param name new value of name
- */
- public void setName(String name) {
- this.name = name;
- }
-
- /**
- * Get the value of description
- *
- * @return the value of description
- */
- public String getDescription() {
- return description;
- }
-
- /**
- * Set the value of description
- *
- * @param description new value of description
- */
- public void setDescription(String description) {
- this.description = description;
- }
+ public int getId() { return id; }
+ public String getName() { return name; }
+ public String getDescription() { return description; }
}
diff --git a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/service/ItemService.java b/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/service/ItemService.java
deleted file mode 100644
index fe2a45f..0000000
--- a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/service/ItemService.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.owasp.encoder.testing.jakarta_test.service;
-
-import java.util.Collection;
-import org.owasp.encoder.testing.jakarta_test.dto.Item;
-
-/**
- *
- * @author jeremy
- */
-public interface ItemService {
- Collection
- getItems();
-
- Item addItem(Item item);
-}
diff --git a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/service/impl/ItemServiceImpl.java b/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/service/impl/ItemServiceImpl.java
deleted file mode 100644
index 4807594..0000000
--- a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/service/impl/ItemServiceImpl.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package org.owasp.encoder.testing.jakarta_test.service.impl;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import org.owasp.encoder.testing.jakarta_test.dto.Item;
-import org.owasp.encoder.testing.jakarta_test.service.ItemService;
-import org.springframework.stereotype.Service;
-
-/**
- *
- * @author jeremy
- */
-@Service
-public class ItemServiceImpl implements ItemService {
-
- @Override
- public Collection
- getItems() {
- Collection
- items = new ArrayList<>();
- items.add(new Item(1, "menu", "blob"));
- items.add(new Item(2, "top", "fancy "));
- return items;
- }
-
- @Override
- public Item addItem(Item item) {
- throw new UnsupportedOperationException("Not supported yet."); // Generated from nbfs://nbhost/SystemFileSystem/Templates/Classes/Code/GeneratedMethodBody
- }
-
-}
diff --git a/jakarta-test/src/main/webapp/WEB-INF/jsp/index.jsp b/jakarta-test/src/main/webapp/WEB-INF/jsp/index.jsp
index 7abf69b..305aed6 100644
--- a/jakarta-test/src/main/webapp/WEB-INF/jsp/index.jsp
+++ b/jakarta-test/src/main/webapp/WEB-INF/jsp/index.jsp
@@ -1,4 +1,5 @@
-<%@page contentType="text/html" pageEncoding="UTF-8"%>
+<%@page session="false" contentType="text/html" pageEncoding="UTF-8"%>
+<%@taglib prefix="c" uri="jakarta.tags.core"%>
@@ -7,6 +8,6 @@
Hello World!
- You are likely looking for the test page located here.
+ You are likely looking for the test page located ">here.
diff --git a/jakarta-test/src/main/webapp/WEB-INF/jsp/view-items.jsp b/jakarta-test/src/main/webapp/WEB-INF/jsp/view-items.jsp
index 69e2488..769092a 100644
--- a/jakarta-test/src/main/webapp/WEB-INF/jsp/view-items.jsp
+++ b/jakarta-test/src/main/webapp/WEB-INF/jsp/view-items.jsp
@@ -1,6 +1,7 @@
-<%@page contentType="text/html;charset=UTF-8" language="java"%>
+<%@page session="false" contentType="text/html;charset=UTF-8" language="java"%>
<%@taglib prefix="c" uri="jakarta.tags.core"%>
<%@taglib prefix="e" uri="owasp.encoder.jakarta"%>
+
View Items
diff --git a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/BrowserFixture.java b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/BrowserFixture.java
new file mode 100644
index 0000000..ba702e6
--- /dev/null
+++ b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/BrowserFixture.java
@@ -0,0 +1,21 @@
+package org.owasp.encoder.testing.jakarta_test;
+
+import org.openqa.selenium.chrome.ChromeOptions;
+import org.testcontainers.containers.BrowserWebDriverContainer;
+import org.testcontainers.utility.DockerImageName;
+
+/** One reviewed image for every required browser test; see README.md. */
+final class BrowserFixture {
+ private static final String IMAGE = "selenium/standalone-chrome:4.49.0-20260909@sha256:7efe71e7e4a83bdf574b26bd354690928075e8f443223d2ced16a2c208eae1d7";
+
+ static BrowserWebDriverContainer> container(ChromeOptions options) {
+ return new BrowserWebDriverContainer<>(DockerImageName.parse(IMAGE)
+ // Testcontainers 2 parses tag+digest names differently during its compatibility check.
+ .asCompatibleSubstituteFor("selenium/standalone-chrome"))
+ .withCapabilities(options)
+ .withSharedMemorySize(2L * 1024 * 1024 * 1024)
+ .withRecordingMode(BrowserWebDriverContainer.VncRecordingMode.SKIP, null);
+ }
+
+ private BrowserFixture() { }
+}
diff --git a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/ItemControllerTest.java b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/ItemControllerTest.java
index c08cbb4..19df57d 100644
--- a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/ItemControllerTest.java
+++ b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/ItemControllerTest.java
@@ -1,12 +1,11 @@
package org.owasp.encoder.testing.jakarta_test;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
-import org.openqa.selenium.NoSuchElementException;
-import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.springframework.beans.factory.annotation.Autowired;
@@ -15,51 +14,42 @@
import org.springframework.core.env.Environment;
import org.testcontainers.Testcontainers;
import org.testcontainers.containers.BrowserWebDriverContainer;
-import org.testcontainers.junit.jupiter.Container;
-/**
- *
- * @author jeremy
- */
+/** Actual browser interpretation of server-rendered tag and EL output. */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
-public class ItemControllerTest {
-
- @Container
- static BrowserWebDriverContainer> container = new BrowserWebDriverContainer<>().
- withCapabilities(new ChromeOptions());
+class ItemControllerTest {
+ private static BrowserWebDriverContainer> container;
+ private static RemoteWebDriver browser;
@LocalServerPort
private int port;
@BeforeAll
- static void beforeAll(@Autowired Environment environment) {
- Testcontainers.exposeHostPorts(environment.getProperty("local.server.port", Integer.class));
+ static void start(@Autowired Environment environment) {
+ Testcontainers.exposeHostPorts(environment.getRequiredProperty("local.server.port", Integer.class));
+ ChromeOptions options = new ChromeOptions().addArguments("--headless=new");
+ container = BrowserFixture.container(options);
container.start();
+ browser = new RemoteWebDriver(container.getSeleniumAddress(), options);
}
- @Test
- void shouldDisplayMessage() {
- RemoteWebDriver browser = new RemoteWebDriver(container.getSeleniumAddress(), new ChromeOptions());
- browser.get("http://host.testcontainers.internal:" + port + "/jakarta-test/item/viewItems");
- WebElement first = browser.findElement(By.id("b2"));
- WebElement second = browser.findElement(By.id("c2"));
- assertEquals("top", first.getText());
- assertEquals("fancy ", second.getText());
- //todo yes - there are much better ways to check for an exception in junit
- NoSuchElementException exception = null;
+ @AfterAll
+ static void stop() {
try {
- first.findElement(By.tagName("script"));
- } catch (NoSuchElementException ex) {
- exception = ex;
+ if (browser != null) browser.quit();
+ } finally {
+ if (container != null) container.stop();
}
- assertNotNull(exception);
+ }
- exception = null;
- try {
- second.findElement(By.tagName("script"));
- } catch (NoSuchElementException ex) {
- exception = ex;
- }
- assertNotNull(exception);
+ @Test
+ void rendersTagAndFunctionAsTextWithoutCreatingScripts() {
+ browser.get("http://host.testcontainers.internal:" + port + "/jakarta-test/item/viewItems");
+ assertEquals("View Items", browser.getTitle());
+ assertEquals(2, browser.findElements(By.cssSelector("tbody tr")).size());
+ assertEquals("top", browser.findElement(By.id("b2")).getText());
+ assertEquals("fancy ", browser.findElement(By.id("c2")).getText());
+ assertTrue(browser.findElements(By.tagName("script")).isEmpty());
+ assertEquals("CSS1Compat", browser.executeScript("return document.compatMode"));
}
}
diff --git a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/JakartaTestApplicationTests.java b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/JakartaTestApplicationTests.java
deleted file mode 100644
index 55a46fd..0000000
--- a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/JakartaTestApplicationTests.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package org.owasp.encoder.testing.jakarta_test;
-
-import org.junit.jupiter.api.Test;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.context.annotation.Import;
-
-@Import(TestcontainersConfiguration.class)
-@SpringBootTest
-class JakartaTestApplicationTests {
-
- @Test
- void contextLoads() {
- }
-
-}
diff --git a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/JavaScriptTemplateTest.java b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/JavaScriptTemplateTest.java
index f190066..1cb1956 100644
--- a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/JavaScriptTemplateTest.java
+++ b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/JavaScriptTemplateTest.java
@@ -41,7 +41,7 @@ static void startBrowser() {
if (Boolean.getBoolean("encoder.browser.local")) {
browser = new ChromeDriver(options);
} else {
- container = new BrowserWebDriverContainer<>().withCapabilities(options);
+ container = BrowserFixture.container(options);
container.start();
browser = new RemoteWebDriver(container.getSeleniumAddress(), options);
}
@@ -60,11 +60,10 @@ void inputCannotCompleteInterpolationAfterTrustedDollar() {
@AfterAll
static void stopBrowser() {
- if (browser != null) {
- browser.quit();
- }
- if (container != null) {
- container.stop();
+ try {
+ if (browser != null) browser.quit();
+ } finally {
+ if (container != null) container.stop();
}
}
diff --git a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/PackagedWarIT.java b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/PackagedWarIT.java
new file mode 100644
index 0000000..806a8ac
--- /dev/null
+++ b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/PackagedWarIT.java
@@ -0,0 +1,80 @@
+package org.owasp.encoder.testing.jakarta_test;
+
+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.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+import java.util.zip.ZipFile;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.owasp.encoder.Encode;
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Starts the executable WAR itself, including its packaged JSP/JSTL dependencies. */
+class PackagedWarIT {
+ @Test
+ @Timeout(90)
+ void executableWarStartsAndRendersPackagedViews() throws Exception {
+ Path war = Path.of(System.getProperty("fixture.war")).toAbsolutePath();
+ try (ZipFile zip = new ZipFile(war.toFile())) {
+ for (String prefix : new String[]{"WEB-INF/lib/jakarta.servlet.jsp.jstl-api-",
+ "WEB-INF/lib/jakarta.servlet.jsp.jstl-", "WEB-INF/lib/encoder-jakarta-jsp-"}) {
+ assertTrue(zip.stream().anyMatch(entry -> entry.getName().startsWith(prefix)), prefix);
+ }
+ assertNotNull(zip.getEntry("WEB-INF/jsp/view-items.jsp"));
+ for (String prefix : new String[]{"WEB-INF/lib/jakarta.el-api-", "WEB-INF/lib/jakarta.servlet-api-",
+ "WEB-INF/lib/jakarta.servlet.jsp-api-"}) {
+ assertTrue(zip.stream().noneMatch(entry -> entry.getName().startsWith(prefix)), prefix);
+ }
+ }
+ Path log = war.getParent().resolve("packaged-war.log");
+ Process server = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(),
+ "-jar", war.toString(), "--server.address=127.0.0.1", "--server.port=0")
+ .redirectErrorStream(true).redirectOutput(log.toFile()).start();
+ try {
+ var portPattern = Pattern.compile("Tomcat started on port (\\d+)");
+ int port = 0;
+ long deadline = System.nanoTime() + Duration.ofSeconds(60).toNanos();
+ while (System.nanoTime() < deadline && server.isAlive()) {
+ var matcher = portPattern.matcher(Files.readString(log));
+ if (matcher.find()) { port = Integer.parseInt(matcher.group(1)); break; }
+ Thread.sleep(100);
+ }
+ assertTrue(port > 0, () -> "WAR failed to start; inspect " + log);
+ HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
+ String root = "http://127.0.0.1:" + port + "/jakarta-test";
+ HttpResponse index = get(client, root + "/");
+ assertEquals(200, index.statusCode());
+ assertTrue(index.body().contains("href=\"/jakarta-test/item/viewItems\""), index.body());
+ HttpResponse page = get(client, root + "/item/viewItems");
+ assertEquals(200, page.statusCode(), page.body());
+ assertEquals(Encode.forHtml("top"), cell(page.body(), "b2"));
+ assertEquals(Encode.forHtml("fancy "), cell(page.body(), "c2"));
+ assertFalse(page.body().contains("