This is an automated email from the ASF dual-hosted git repository. lukaszlenart pushed a commit to branch WW-5791-struts-boot in repository https://gitbox.apache.org/repos/asf/struts.git
commit 1b6a1cac852a1c4b885953e1803aa27ed24a9f1d Author: Lukasz Lenart <[email protected]> AuthorDate: Fri Oct 23 12:33:19 2026 +0200 WW-5791 docs: implementation plan for Struts Boot Co-Authored-By: Claude Opus 5.5 <[email protected]> --- .../plans/2026-10-23-WW-5791-struts-boot.md | 444 +++++++++++++++++++++ 1 file changed, 444 insertions(+) diff --git a/docs/superpowers/plans/2026-10-23-WW-5791-struts-boot.md b/docs/superpowers/plans/2026-10-23-WW-5791-struts-boot.md new file mode 100644 index 000000000..9979d3679 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-WW-5791-struts-boot.md @@ -0,0 +1,444 @@ +# WW-5791 Struts Boot Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A newcomer adds one dependency, registers an action in Java, runs `main()`, and gets a FreeMarker page from embedded Tomcat. No XML, no Convention, no Spring. + +**Architecture:** +- A new top-level `boot/` module with three parts: + - a fluent registry that only records definitions + - a `ServletContextAwareConfigurationProvider` that turns those definitions into ordinary `PackageConfig`s + - a launcher that starts Tomcat, registers `StrutsPrepareAndExecuteFilter` with `configProviders=<provider>`, and passes the registry to the provider through a servlet-context attribute +- Core is untouched. + +**Tech Stack:** +- Java 17 +- Struts core 7.5.0-SNAPSHOT +- `tomcat-embed-core`: 10.1.x by default, 11.0.x under `-Pjakartaee11` +- FreeMarker +- JUnit 4, AssertJ, `java.net.http.HttpClient` +- `maven-shade-plugin` for the example app + +**Spec:** `docs/superpowers/specs/2026-10-23-WW-5791-struts-boot-design.md` + +## Global Constraints + +- No file under `core/` changes. If something can't be done through existing extension points, stop and raise it. +- No Spring artifact in `struts2-boot-starter`'s dependency tree. Check with `./mvnw -pl boot dependency:tree`. +- Registry config must equal what the equivalent `struts.xml` would produce. Never relax strict method invocation, `@StrutsParameter` or the allowlist. Never set `struts.enable.SlashesInActionNames` or `struts.devMode`. +- Never read `application.properties`. Settings come from `struts.properties`, with Boot keys `struts.boot.port` (default `8080`) and `struts.boot.context-path` (default `""`). `--key=value` arguments override the file. +- FreeMarker only. Result paths resolve to `/templates/` + path on the classpath. +- Tomcat follows core's Jakarta line. Root property `tomcat-embed.version` is `10.1.59`, and `11.0.27` in the `jakartaee11` profile. +- Configuration errors are `org.apache.struts2.config.ConfigurationException`, thrown at startup. +- Tests are JUnit 4 (`org.junit.Test`). JUnit 5 is never used. +- Package: `org.apache.struts2.boot`. Artifact: `org.apache.struts:struts2-boot-starter`. +- Tomcat base dir: `Files.createTempDirectory("struts-boot-" + UUID.randomUUID())`, deleted recursively on close. +- Default to no comments (see `~/.claude/CLAUDE.md`). Every new file carries the ASF license header, copied from any existing source file. + +## Review Focus + +1. **Struts tags inside a template.** `<@s.property value="message"/>` must render. `FreemarkerManager` builds a `TaglibFactory`, which needs `jakarta.servlet.jsp` classes that `tomcat-embed-core` doesn't ship. Pinned in Task 4 `testStrutsTagRenders`. +2. **Startup failure leaves nothing behind.** A `ConfigurationException` or a busy port must not leave a running Tomcat, a hung JVM or a leftover temp dir. Pinned in Task 4 `testConfigurationErrorStopsTomcatAndCleansBaseDir` and `testBusyPortFailsCleanly`. +3. **Non-empty context path.** Actions answer under `/ctx/...`, and `redirectAction` carries the context path. Pinned in Task 4 `testContextPathAppliesToActionsAndRedirects`. +4. **`namespace("/admin")` called twice.** Both calls land in one package, and a duplicate name across the two calls throws. Pinned in Task 1 `testNamespaceDeclaredTwiceMerges` and `testDuplicateAcrossRepeatedNamespaceThrows`. +5. **Argument precedence.** `--struts.boot.port=9090` beats `struts.boot.port=8081` in `struts.properties`, and unknown arguments are ignored rather than fatal. Pinned in Task 3. + +--- + +## File Structure + +``` +pom.xml modify: <module>boot</module>; tomcat-embed.version property + jakartaee11 override +parent/pom.xml modify: dependencyManagement for tomcat-embed-core, struts2-boot-starter +bom/pom.xml modify: struts2-boot-starter entry +boot/pom.xml create +boot/src/main/java/org/apache/struts2/boot/ + StrutsBootApplication.java user-facing: void actions(ActionRegistry) + ActionRegistry.java user-facing: top-level actions + namespace(...) + NamespaceActions.java user-facing: actions + interceptors inside one namespace + ActionDefinition.java user-facing: fluent per-action calls + HttpMethods.java user-facing: static GET/POST/... aliases for HttpMethod + RegistryConfigurationProvider.java internal: registry -> PackageConfig, all validation + BootSettings.java internal: struts.properties + args + StrutsBoot.java user-facing: run(...) / start(...) + StrutsBootServer.java user-facing: running-server handle +boot/src/test/java/org/apache/struts2/boot/... tests + fixtures +boot/src/test/resources/templates/... fixture templates +apps/pom.xml modify: <module>boot-hello</module> +apps/boot-hello/... create: example app, shade config, smoke test +``` + +--- + +### Task 1: Module scaffolding and the fluent registry + +The registry only records definitions. It checks the rules that need no Struts configuration: slashes, `.ftl`, and duplicates inside the registry. Everything that needs Struts belongs to Task 2. + +**Files:** +- Modify: `pom.xml` + - add `<module>boot</module>` between `plugins` and `apps` + - add `<tomcat-embed.version>10.1.59</tomcat-embed.version>` to the properties + - add `<tomcat-embed.version>11.0.27</tomcat-embed.version>` to the `jakartaee11` profile properties +- Modify: `parent/pom.xml`: in `dependencyManagement`, add `org.apache.tomcat.embed:tomcat-embed-core:${tomcat-embed.version}` and `org.apache.struts:struts2-boot-starter:${project.version}` +- Modify: `bom/pom.xml`: add `struts2-boot-starter`, following the shape of the existing entries +- Create: `boot/pom.xml` + - parent `struts2-parent`, `relativePath` `../parent/pom.xml` + - artifactId `struts2-boot-starter`, name `Struts 2 Boot Starter` + - compile dependencies: `struts2-core`, `tomcat-embed-core` + - test dependencies: junit, assertj-core +- Create: `StrutsBootApplication.java`, `ActionRegistry.java`, `NamespaceActions.java`, `ActionDefinition.java`, `HttpMethods.java` +- Test: `boot/src/test/java/org/apache/struts2/boot/ActionRegistryTest.java` + +`plugins/jasperreports7` keeps its own `tomcat-embed.version` property. Leave it alone. + +**Interfaces:** +- Produces: + - `interface StrutsBootApplication { void actions(ActionRegistry actions); }` + - `final class ActionRegistry`: + - `public static final String SERVLET_CONTEXT_ATTRIBUTE = ActionRegistry.class.getName()` + - `ActionDefinition action(String name, Class<?> actionClass)`: namespace `""` + - `ActionRegistry namespace(String namespace, Consumer<NamespaceActions> body)` + - `Collection<NamespaceActions> namespaces()`: insertion order; `""` is included only when used + - `final class NamespaceActions`: + - `ActionDefinition action(String name, Class<?> actionClass)` + - `NamespaceActions interceptors(String stackName)` + - `String namespace()` + - `Optional<String> interceptorStack()` + - `List<ActionDefinition> actions()` + - `final class ActionDefinition`: + - fluent methods, each returning `this`: + - `method(String)` + - `success(String templatePath)` + - `result(String name, String templatePath)` + - `redirectAction(String resultName, String actionName)` + - `httpMethods(HttpMethod...)` + - `interceptors(String stackName)` + - read accessors: `name()`, `actionClass()`, `methodName()` (default `"execute"`), `results()` (`Map<String, ResultDefinition>`, insertion order), `httpMethodSet()`, `interceptorStack()` (`Optional<String>`) + - `record ResultDefinition(Kind kind, String value)` nested in `ActionDefinition`, with `enum Kind { TEMPLATE, REDIRECT_ACTION }` + - `final class HttpMethods`: `public static final HttpMethod GET = HttpMethod.GET`, and the same for `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`. Users can `import static ...HttpMethods.POST`. + +- [ ] **Step 1: Write the failing tests** in `ActionRegistryTest`: + +```java +@Test public void testTopLevelActionGoesToEmptyNamespace() { + ActionRegistry r = new ActionRegistry(); + r.action("hello", Object.class).success("hello.ftl"); + assertThat(r.namespaces()).extracting(NamespaceActions::namespace).containsExactly(""); + ActionDefinition a = r.namespaces().iterator().next().actions().get(0); + assertThat(a.methodName()).isEqualTo("execute"); + assertThat(a.results()).containsEntry("success", new ResultDefinition(Kind.TEMPLATE, "hello.ftl")); +} +@Test public void testNamespaceDeclaredTwiceMerges() { + ActionRegistry r = new ActionRegistry(); + r.namespace("/admin", n -> n.action("a", Object.class)); + r.namespace("/admin", n -> n.action("b", Object.class)); + assertThat(r.namespaces()).hasSize(1); + assertThat(r.namespaces().iterator().next().actions()).extracting(ActionDefinition::name).containsExactly("a", "b"); +} +@Test public void testDuplicateAcrossRepeatedNamespaceThrows() { + ActionRegistry r = new ActionRegistry(); + r.namespace("/admin", n -> n.action("a", Object.class)); + assertThatThrownBy(() -> r.namespace("/admin", n -> n.action("a", Object.class))) + .isInstanceOf(ConfigurationException.class).hasMessageContaining("/admin").hasMessageContaining("a"); +} +@Test public void testDuplicateTopLevelThrows() // two r.action("hello", ...) -> ConfigurationException +@Test public void testSlashInActionNameThrows() { // r.action("users/save", ...) -> ConfigurationException, message contains "namespace" +@Test public void testNonFtlTemplateThrows() // .success("hello.jsp") -> ConfigurationException, message contains "FreeMarker" +@Test public void testFluentCallsAreRecorded() { + ActionDefinition a = new ActionRegistry().action("save", Object.class) + .method("save").httpMethods(HttpMethods.POST) + .result("input", "form.ftl").redirectAction("success", "users").interceptors("paramsPrepareParamsStack"); + assertThat(a.methodName()).isEqualTo("save"); + assertThat(a.httpMethodSet()).containsExactly(HttpMethod.POST); + assertThat(a.results()).containsEntry("success", new ResultDefinition(Kind.REDIRECT_ACTION, "users")); + assertThat(a.interceptorStack()).contains("paramsPrepareParamsStack"); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + - Run: `./mvnw test -DskipAssembly -pl boot -am -Dtest=ActionRegistryTest -Dsurefire.failIfNoSpecifiedTests=false > target/boot-t1.log 2>&1; echo $?` + - Expected: non-zero exit, with a compilation failure for the missing classes. + +- [ ] **Step 3: Implement** the five classes, the POM changes and the module. + - Duplicate and slash checks run when `action(...)` is called. + - The `.ftl` check runs in `result(...)` and `success(...)`. + +- [ ] **Step 4: Run the tests to verify they pass** + - Same command. Expected: exit 0, `Tests run: 7, Failures: 0`. + - Then run `./mvnw -pl boot dependency:tree | grep -i spring`. Expected: no output. + +- [ ] **Step 5: Commit** + +```bash +git add pom.xml parent/pom.xml bom/pom.xml boot/ +git commit -m "WW-5791 feat(boot): add struts2-boot-starter module with fluent action registry" +``` + +--- + +### Task 2: Registry → PackageConfig provider + +**Files:** +- Create: `boot/src/main/java/org/apache/struts2/boot/RegistryConfigurationProvider.java` +- Test: `boot/src/test/java/org/apache/struts2/boot/RegistryConfigurationProviderTest.java` +- Test fixtures: + - `boot/src/test/java/org/apache/struts2/boot/fixture/UserAction.java`: methods `execute`, `list`, `save`, `delete` + - `boot/src/test/resources/templates/hello.ftl` + - `boot/src/test/resources/templates/admin/users.ftl` + - `boot/src/test/resources/struts-boot-clash.xml`: package `xml-pkg`, namespace `/admin`, action `users` + +**Interfaces:** +- Consumes: `ActionRegistry`, `NamespaceActions`, `ActionDefinition`, `ResultDefinition` from Task 1. +- Produces: `public class RegistryConfigurationProvider implements ServletContextAwareConfigurationProvider`, with: + - `public RegistryConfigurationProvider()`: used by `Dispatcher` through `configProviders`. `initWithContext` reads `ActionRegistry.SERVLET_CONTEXT_ATTRIBUTE`. If the attribute is missing, it throws `ConfigurationException`. + - `RegistryConfigurationProvider(ActionRegistry registry)`: package-private, for tests. + - Package names: `struts-boot` for namespace `""`, and `"struts-boot" + namespace` otherwise, for example `struts-boot/admin`. + +Translation rules (from the spec): +- Each package uses `addParent(configuration.getPackageConfig("struts-default"))` and the definition's namespace. `strictMethodInvocation` keeps its default (`true`). +- Each action is `new ActionConfig.Builder(pkgName, name, actionClass.getName())`, with `methodName(m)` and `addAllowedMethod(m)`. + - If `httpMethodSet()` is non-empty, call `addHttpMethods(set)`. + - The interceptor stack comes from the action first, then the namespace. If either is set, call `addInterceptors(InterceptorBuilder.constructInterceptorReference(pkgBuilder, stack, Map.of(), null, objectFactory))`. If neither is set, add no interceptors, so the package default (`defaultStack`) applies. +- A `TEMPLATE` result becomes `new ResultConfig.Builder(name, freemarkerClass).addParam("location", "/templates/" + path)`. +- A `REDIRECT_ACTION` result becomes `new ResultConfig.Builder(name, redirectActionClass).addParam("actionName", target)`. +- Result classes come from `strutsDefault.getAllResultTypeConfigs().get("freemarker" | "redirectAction").getClassName()`. + +Validation, each a `ConfigurationException` thrown from `loadPackages()`: +- The template `templates/<path>` is missing on the classpath. Check with `ClassLoaderUtil.getResource`. +- An unknown stack name. `constructInterceptorReference` already throws this. +- A non-abstract package already in `configuration.getPackageConfigs()` has the same namespace and an action with the same name. The message names the namespace, the action and the XML package. + +- [ ] **Step 1: Write the failing tests.** + +Build the configuration the way `ScopedConstantsSubstitutionTest` does: + +```java +private Configuration load(ActionRegistry registry, String... extraXml) { + ConfigurationManager cm = new ConfigurationManager(Container.DEFAULT_NAME); + cm.addContainerProvider(new DefaultPropertiesProvider()); + cm.addContainerProvider(new StrutsXmlConfigurationProvider("struts-default.xml")); + for (String xml : extraXml) cm.addContainerProvider(new StrutsXmlConfigurationProvider(xml)); + cm.addContainerProvider(new StrutsBeanSelectionProvider()); + cm.addContainerProvider(new RegistryConfigurationProvider(registry)); + return cm.getConfiguration(); +} +``` + +Write these tests: +- `testPackageExtendsStrutsDefaultWithNamespace`: `getPackageConfig("struts-boot/admin")` has namespace `/admin`, parent `struts-default`, and `isStrictMethodInvocation()` true. +- `testMethodIsAddedToAllowedMethods`: action `users` with `.method("list")` gives `getAllowedMethods().isAllowed("list")` true and `isAllowed("delete")` false. +- `testTemplateResultIsAbsoluteFreemarker`: the `success` result's class name equals struts-default's `freemarker` type, and its `location` param is `/templates/admin/users.ftl`. +- `testRedirectActionResult`: the class is the `redirectAction` type, and the `actionName` param is `users`. +- `testHttpMethodsCarried`: `.httpMethods(POST)` gives `getHttpMethods()` containing exactly `POST`. +- `testNoStackMeansPackageDefault`: the action's `getInterceptors()` is empty, and the `RuntimeConfiguration`'s resolved config for the action has a non-empty interceptor list. +- `testNamedStackResolved`: `.interceptors("basicStack")` gives a non-empty interceptor list. +- `testUnknownStackThrows`: `.interceptors("noSuchStack")` makes `load(...)` throw `ConfigurationException`. +- `testMissingTemplateThrows`: `.success("missing.ftl")` throws a `ConfigurationException` whose message contains `templates/missing.ftl`. +- `testClashWithXmlThrows`: the registry defines `users` in `/admin`, and `load(registry, "struts-boot-clash.xml")` throws a `ConfigurationException` whose message contains `xml-pkg` and `users`. +- `testSameNamespaceAsXmlWithDistinctNamesLoads`: the registry defines `list` in `/admin` alongside the XML `users`, and both actions resolve through `getRuntimeConfiguration().getActionConfig("/admin", ...)`. + +- [ ] **Step 2: Run them to verify they fail** + - Run: `./mvnw test -DskipAssembly -pl boot -Dtest=RegistryConfigurationProviderTest > target/boot-t2.log 2>&1; echo $?` + - Expected: non-zero exit, because the provider class doesn't exist yet. + +- [ ] **Step 3: Implement `RegistryConfigurationProvider`.** + - `init(Configuration)` stores the configuration. + - `register(...)` is a no-op. + - `needsReload()` returns `false`. + - `loadPackages()` applies the rules above, using `configuration.getContainer().getInstance(ObjectFactory.class)` as the `ObjectFactory`. + +- [ ] **Step 4: Run the tests to verify they pass** + - Same command. Expected: exit 0, `Failures: 0`, 11 tests. + +- [ ] **Step 5: Commit** + +```bash +git add boot/ +git commit -m "WW-5791 feat(boot): translate the action registry into Struts package configs" +``` + +--- + +### Task 3: Boot settings + +**Files:** +- Create: `boot/src/main/java/org/apache/struts2/boot/BootSettings.java` +- Test: `boot/src/test/java/org/apache/struts2/boot/BootSettingsTest.java` + +**Interfaces:** +- Produces `final class BootSettings` with: + - `static BootSettings load(String[] args)`, which reads `struts.properties` from the context class loader + - `static BootSettings load(Properties fileProperties, String[] args)`, for tests + - `int port()` + - `String contextPath()` +- Precedence: `--struts.boot.port=N` arguments, then `struts.properties`, then the defaults (`8080`, `""`). +- An argument that doesn't match `--struts.boot.<key>=<value>` is ignored. +- A port that isn't numeric throws `ConfigurationException`. + +- [ ] **Step 1: Write the failing tests** + - `testDefaults`: port is `8080` and context path is `""`. + - `testFileValue`: file `struts.boot.port=8081` gives `8081`. + - `testArgumentBeatsFile`: file `8081` and arg `--struts.boot.port=9090` give `9090`. + - `testUnknownArgumentsIgnored`: args `--foo=bar` and `positional` give the defaults with no exception. + - `testContextPathFromArgs`: `--struts.boot.context-path=/ctx` gives `/ctx`. + - `testNonNumericPortThrows`: `--struts.boot.port=abc` throws `ConfigurationException`. + +- [ ] **Step 2: Run them to verify they fail** + - Run: `./mvnw test -DskipAssembly -pl boot -Dtest=BootSettingsTest > target/boot-t3.log 2>&1; echo $?` + - Expected: non-zero exit (compilation failure). + +- [ ] **Step 3: Implement `BootSettings`.** + +- [ ] **Step 4: Run the tests to verify they pass** + - Same command. Expected: exit 0, 6 tests, `Failures: 0`. + +- [ ] **Step 5: Commit** + +```bash +git add boot/ +git commit -m "WW-5791 feat(boot): read port and context path from struts.properties and arguments" +``` + +--- + +### Task 4: Launcher on embedded Tomcat, end to end + +**Files:** +- Create: `boot/src/main/java/org/apache/struts2/boot/StrutsBoot.java`, `boot/src/main/java/org/apache/struts2/boot/StrutsBootServer.java` +- Modify: `boot/pom.xml`. Add `jakarta.servlet.jsp:jakarta.servlet.jsp-api` at runtime scope, with the version the parent already manages, only if `testStrutsTagRenders` fails without it (see Review Focus 1). +- Test: `boot/src/test/java/org/apache/struts2/boot/StrutsBootTomcatTest.java` +- Test fixtures: + - `fixture/HelloApp.java`: `hello` → `hello.ftl`; `/admin` with `users` (`list`) → `admin/users.ftl`, and `saveUser` (`save`, `POST` only) with redirect `success` → `users` + - `fixture/HelloAction.java`: `getMessage()` returns `"Hello Boot"` + - `fixture/BrokenApp.java`: registers `.success("missing.ftl")` + - `hello.ftl`, containing `<@s.property value="message"/>` + - `boot/src/test/resources/struts.xml`: package `xml-pkg`, namespace `/xml`, action `legacy` with a freemarker result `/templates/hello.ftl` + - `boot/src/test/resources/struts.properties`: `struts.enable.DynamicMethodInvocation=true` + +**Interfaces:** +- Consumes `ActionRegistry` (Task 1), `RegistryConfigurationProvider` (Task 2), and `BootSettings` (Task 3). +- Produces: + - `public final class StrutsBoot`: + - `static void run(Class<? extends StrutsBootApplication> app, String... args)`: starts the server and blocks on `await()`. On a startup failure it rethrows, which a newcomer's `main` reports as a non-zero exit. + - `static StrutsBootServer start(Class<? extends StrutsBootApplication> app, String... args)`: returns once Tomcat is serving. + - `public final class StrutsBootServer implements AutoCloseable`: + - `int port()`: the actual bound port, which matters when `--struts.boot.port=0` + - `String baseUrl()`: `"http://127.0.0.1:" + port + contextPath` + - `Path baseDir()` + - `void await()` + - `void close()`: stops and destroys Tomcat, then deletes `baseDir` + +`start` sequence, following the pattern in `JasperReport7TomcatTest`: +1. Instantiate the app with its no-arg constructor and call `actions(registry)`. +2. Create the temp base dir. +3. Create `new Tomcat()` with `setBaseDir` and `setPort`, then `addContext(contextPath, baseDir)`. Put the registry into the context's servlet context under `SERVLET_CONTEXT_ATTRIBUTE` (through a `ServletContainerInitializer` or `context.getServletContext().setAttribute` before start). +4. Add a `FilterDef` for `StrutsPrepareAndExecuteFilter` with init param `configProviders=RegistryConfigurationProvider.class.getName()`, mapped to `/*`. +5. Start Tomcat. If the context fails to start (`context.getState() != LifecycleState.STARTED`), or anything throws, call `close()` and throw a `ConfigurationException` that carries the cause. +6. Register a shutdown hook that calls `close()`. `close()` must be idempotent. + +Tests: the class starts one `@BeforeClass` server with `StrutsBoot.start(HelloApp.class, "--struts.boot.port=0")` and calls `close()` in `@AfterClass`. Each failure-path test starts its own server. +- `testRendersFreemarkerTemplate`: `GET /hello.action` returns 200. +- `testStrutsTagRenders`: the same response body contains `Hello Boot`. +- `testPostOnlyRejectsGet`: + - `GET /admin/saveUser.action` returns 405. + - `POST` to the same URL returns 302, with a `Location` ending in `/admin/users.action`. + - Because the POST reaches the action, the GET rejection comes from the method restriction rather than a 404. +- `testUnregisteredMethodNotInvokable`: with DMI on through the fixture `struts.properties`, `GET /admin/users!delete.action` does not invoke `delete` (`UserAction` records invocations in a static flag the test resets). The response is not 200. +- `testXmlAndRegistryCoexist`: both `GET /xml/legacy.action` and `GET /hello.action` return 200. +- `testConfigurationErrorStopsTomcatAndCleansBaseDir`: + - `StrutsBoot.start(BrokenApp.class, "--struts.boot.port=0")` throws a `ConfigurationException` whose message contains `templates/missing.ftl`. + - The temp-dir glob `struts-boot-*` gains no new directory. Snapshot it before and after. +- `testBusyPortFailsCleanly`: + - Hold a `ServerSocket` on a free port, then `start` with that port. It throws. + - No new `struts-boot-*` temp dir remains. +- `testContextPathAppliesToActionsAndRedirects`: + - Start with `--struts.boot.port=0 --struts.boot.context-path=/ctx`. + - `GET /ctx/hello.action` returns 200. + - `POST /ctx/admin/saveUser.action` returns a `Location` containing `/ctx/admin/users.action`. +- `testCloseDeletesBaseDir`: after `close()`, `Files.exists(server.baseDir())` is false. + +Use `HttpClient` with `followRedirects(NEVER)`. + +- [ ] **Step 1: Write the failing tests and fixtures.** + +- [ ] **Step 2: Run them to verify they fail** + - Run: `./mvnw test -DskipAssembly -pl boot -Dtest=StrutsBootTomcatTest > target/boot-t4.log 2>&1; echo $?` + - Expected: non-zero exit (compilation failure). + +- [ ] **Step 3: Implement `StrutsBoot` and `StrutsBootServer`.** + - If `testStrutsTagRenders` fails with `NoClassDefFoundError: jakarta/servlet/jsp/...`, add the runtime dependency listed under Files. + +- [ ] **Step 4: Run the tests to verify they pass** + - Run the same command, then the whole module under both Jakarta lines: + - `./mvnw test -DskipAssembly -pl boot > target/boot-ee10.log 2>&1; echo $?` + - `./mvnw test -DskipAssembly -pl boot -Pjakartaee11 > target/boot-ee11.log 2>&1; echo $?` + - Expected: exit 0 for both, with `Failures: 0, Errors: 0`. + - Then run `./mvnw -pl boot dependency:tree -Pjakartaee11 | grep tomcat-embed-core`. Expected: `11.0.27`. Without the profile, expected: `10.1.59`. + +- [ ] **Step 5: Commit** + +```bash +git add boot/ +git commit -m "WW-5791 feat(boot): launch Struts on embedded Tomcat from main()" +``` + +--- + +### Task 5: Example app and runnable jar + +**Files:** +- Modify: `apps/pom.xml`: add `<module>boot-hello</module>` +- Create: `apps/boot-hello/pom.xml` + - artifactId `struts2-boot-hello`, packaging `jar` + - dependency `struts2-boot-starter` + - `maven-shade-plugin` bound to `package`, configured with: + - a `ManifestResourceTransformer` whose `mainClass` is `org.apache.struts2.boot.hello.HelloApp` + - a `ServicesResourceTransformer` + - signature-file excludes (`META-INF/*.SF`, `*.DSA`, `*.RSA`) +- Create: `apps/boot-hello/src/main/java/org/apache/struts2/boot/hello/HelloApp.java` and `HelloAction.java`. This is the spec's newcomer example, trimmed to `hello` only. +- Create: `apps/boot-hello/src/main/resources/templates/hello.ftl` +- Create: `apps/boot-hello/README.md`, covering the three newcomer steps, `java -jar`, `struts.boot.*` settings, and the shading limitation with plugins (two `struts-plugin.xml` files overwrite each other) +- Test: `apps/boot-hello/src/test/java/org/apache/struts2/boot/hello/HelloAppTest.java` + +**Interfaces:** +- Consumes `StrutsBoot.start`, `StrutsBootServer` (Task 4). + +- [ ] **Step 1: Write the failing smoke test.** `testHelloPageRenders` starts `HelloApp` on port 0, sends `GET /hello.action`, and expects 200 with the template's text. + +- [ ] **Step 2: Run it to verify it fails** + - Run: `./mvnw test -DskipAssembly -pl apps/boot-hello -am > target/boot-t5.log 2>&1; echo $?` + - Expected: non-zero exit. + +- [ ] **Step 3: Implement the app, the POM and the README.** + +- [ ] **Step 4: Verify the test and the runnable jar** + - Rerun the Step 2 command. Expected: exit 0. + - Build the jar: `./mvnw package -DskipAssembly -DskipTests -pl apps/boot-hello -am > target/boot-pkg.log 2>&1; echo $?` + - Start it: `java -jar apps/boot-hello/target/struts2-boot-hello-7.5.0-SNAPSHOT.jar --struts.boot.port=18080 &` + - Fetch the page: `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18080/hello.action`. Expected: `200`. + - Kill the process. The `struts-boot-*` temp dir from this run is gone. + +- [ ] **Step 5: Commit** + +```bash +git add apps/pom.xml apps/boot-hello/ +git commit -m "WW-5791 feat(boot): add boot-hello example app with runnable jar" +``` + +--- + +### Task 6: Full reactor check + +- [ ] **Step 1:** `./mvnw clean install -DskipAssembly > target/boot-reactor.log 2>&1; echo $?`. Expected: exit 0. + - The Tiles plugin may fail on unmodified `main` (see memory: velocity-tools). If it does, confirm that on `main` before blaming this branch. +- [ ] **Step 2:** `./mvnw clean install -DskipAssembly -Pjakartaee11 > target/boot-reactor-ee11.log 2>&1; echo $?`. Expected: exit 0. +- [ ] **Step 3:** `git status --short`. Expected: no stray `??` files from review agents. +- [ ] **Step 4:** Read `THREAT_MODEL.md` and confirm Boot stays inside the framework's existing trust boundaries: the registry produces nothing `struts.xml` couldn't, and the Tomcat defaults are stated in the README. Do this before any PR. + +## Out of scope for this plan + +- struts-site documentation +- the assembly module +- the 7.5.0 Version Notes entry + +These follow after merge.
