This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/main by this push:
new e8c81e9f2 WW-5702 fix(core): close the scope gaps in HTML5 constraint
derivation (#1934)
e8c81e9f2 is described below
commit e8c81e9f2de9344b25d9d7e7a230e11c7e37bc36
Author: Lukasz Lenart <[email protected]>
AuthorDate: Mon Sep 14 06:56:40 2026 +0200
WW-5702 fix(core): close the scope gaps in HTML5 constraint derivation
(#1934)
* WW-5702 fix(core): close the scope gaps in HTML5 constraint derivation
Items 1, 3, 4, 7 and 13 of the review follow-up to WW-5695.
- Gate derivation on the resolved theme's ancestry including html5, so
xhtml/simple/css_xhtml forms no longer resolve validators and call
getMessage() per field to build a map nothing renders. A custom theme
with `parent = html5` keeps deriving (the walk reuses
Template.getPossibleTemplates and the cached theme.properties).
- Form.getFieldValidators unwraps FieldVisitorValidatorWrapper, so a
textfield behind a visitor validator gets its concrete constraint and
data-msg-<type> instead of data-msg-field-visitor and nothing else.
- Visitor validators are resolved once per form, not once per field.
- Checkbox and Hidden report their real HtmlControlType; inert with the
default provider, which admits neither, but honest for replacements.
Item 3 (action-less forms resolving under an empty context) turned out
to be a false finding: Component.findValue(null, String.class) returns
null because containsExpression(null) is false, and
DefaultActionMapper.parseActionName returns null for a null name, so the
attributes.actionName fallback is reachable. The new test proves alias-
scoped validators are found for a form without an action attribute; no
production change was needed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* WW-5702 refactor(core): address Sonar findings on the constraint
scope-gap fix
S1117: the local in themeRendersConstraints() hid the template field.
S8924: Mockito's BDD `then(mock).should()` replaces the fully-qualified
`Mockito.verify`, which was qualified only because AbstractUITagTest
inherits verify(URL) and an inherited method shadows a static import.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../org/apache/struts2/components/Checkbox.java | 5 ++
.../java/org/apache/struts2/components/Form.java | 10 ++-
.../java/org/apache/struts2/components/Hidden.java | 5 ++
.../java/org/apache/struts2/components/UIBean.java | 24 ++++++-
.../struts2/components/ConstraintAction.java | 20 ++++++
.../components/ConstraintAttributesTest.java | 40 +++++++++++-
.../{ConstraintAction.java => ConstraintUser.java} | 36 ++---------
.../apache/struts2/components/ControlTypeTest.java | 18 ++++--
.../components/FormFieldValidatorsTest.java | 75 ++++++++++++++++++++--
...nstraintAction-constraintAction-validation.xml} | 18 +-----
.../components/ConstraintAction-validation.xml | 5 ++
...alidation.xml => ConstraintUser-validation.xml} | 18 +-----
.../resources/template/html5child/theme.properties | 19 ++++++
13 files changed, 218 insertions(+), 75 deletions(-)
diff --git a/core/src/main/java/org/apache/struts2/components/Checkbox.java
b/core/src/main/java/org/apache/struts2/components/Checkbox.java
index 2720377dc..b6159bf4e 100644
--- a/core/src/main/java/org/apache/struts2/components/Checkbox.java
+++ b/core/src/main/java/org/apache/struts2/components/Checkbox.java
@@ -74,6 +74,11 @@ public class Checkbox extends UIBean {
return TEMPLATE;
}
+ @Override
+ protected HtmlControlType getControlType() {
+ return HtmlControlType.CHECKBOX;
+ }
+
protected void evaluateExtraParams() {
if (fieldValue != null) {
addParameter(ATTR_FIELD_VALUE, findString(fieldValue));
diff --git a/core/src/main/java/org/apache/struts2/components/Form.java
b/core/src/main/java/org/apache/struts2/components/Form.java
index cb7a4f0ab..d8daf841b 100644
--- a/core/src/main/java/org/apache/struts2/components/Form.java
+++ b/core/src/main/java/org/apache/struts2/components/Form.java
@@ -43,7 +43,9 @@ import
org.apache.struts2.views.annotations.StrutsTagAttribute;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Set;
/**
@@ -127,6 +129,7 @@ public class Form extends ClosingUIBean {
private List<Validator> cachedActionValidators;
private String cachedActionName;
private boolean actionValidatorsResolved;
+ private final Map<Class<?>, List<Validator>> cachedVisitorValidators = new
HashMap<>();
public Form(ValueStack stack, HttpServletRequest request,
HttpServletResponse response) {
super(stack, request, response);
@@ -337,6 +340,10 @@ public class Form extends ClosingUIBean {
Class actionClass = (Class) getAttributes().get(ATTR_ACTION_CLASS);
List<Validator> validators = new ArrayList<>();
findFieldValidators(name, actionClass, cachedActionName,
cachedActionValidators, validators, "");
+ // the wrapper only exists to prefix the field name for the deprecated
JS validator; callers of
+ // this method dispatch on the concrete validator type
+ validators.replaceAll(validator -> validator instanceof
FieldVisitorValidatorWrapper wrapper
+ ? wrapper.getFieldValidator() : validator);
return validators;
}
@@ -393,7 +400,8 @@ public class Form extends ClosingUIBean {
continue;
}
- List<Validator> visitorValidators =
actionValidatorManager.getValidators(clazz, actionName);
+ List<Validator> visitorValidators =
cachedVisitorValidators.computeIfAbsent(clazz,
+ visited ->
actionValidatorManager.getValidators(visited, actionName));
String vPrefix = prefix + (vfValidator.isAppendPrefix() ?
vfValidator.getFieldName() + "." : "");
findFieldValidators(name, clazz, actionName,
visitorValidators, resultValidators, vPrefix);
} else if ((prefix +
fieldValidator.getFieldName()).equals(name)) {
diff --git a/core/src/main/java/org/apache/struts2/components/Hidden.java
b/core/src/main/java/org/apache/struts2/components/Hidden.java
index b8c16237e..46302c422 100644
--- a/core/src/main/java/org/apache/struts2/components/Hidden.java
+++ b/core/src/main/java/org/apache/struts2/components/Hidden.java
@@ -63,4 +63,9 @@ public class Hidden extends UIBean {
return TEMPLATE;
}
+ @Override
+ protected HtmlControlType getControlType() {
+ return HtmlControlType.HIDDEN;
+ }
+
}
diff --git a/core/src/main/java/org/apache/struts2/components/UIBean.java
b/core/src/main/java/org/apache/struts2/components/UIBean.java
index 42d7cbbcd..af466d386 100644
--- a/core/src/main/java/org/apache/struts2/components/UIBean.java
+++ b/core/src/main/java/org/apache/struts2/components/UIBean.java
@@ -450,6 +450,7 @@ public abstract class UIBean extends Component {
static final String TEMPLATE_DIR = "templateDir";
static final String THEME = "theme";
+ private static final String CONSTRAINT_THEME = "html5";
protected static final String ATTR_FIELD_VALUE = "fieldValue";
protected static final String ATTR_NAME_VALUE = "nameValue";
@@ -947,7 +948,7 @@ public abstract class UIBean extends Component {
return;
}
String fieldName = (String) getAttributes().get("name");
- if (fieldName == null) {
+ if (fieldName == null || !themeRendersConstraints()) {
return;
}
int stackDepth = stack.getRoot().size();
@@ -969,6 +970,27 @@ public abstract class UIBean extends Component {
}
}
+ /**
+ * Only {@code html5/common-attributes.ftl} renders the derived map, so
deriving it under any other
+ * theme is wasted work for every field. A custom theme inherits that
template through
+ * {@code parent = html5} in its theme.properties, hence the walk up the
ancestry rather than a
+ * name match. The gate is still by name: a theme that renders {@code
attributes.constraints} itself
+ * without descending from html5 never receives the map.
+ */
+ private boolean themeRendersConstraints() {
+ Template resolved = buildTemplateName(template, getDefaultTemplate());
+ TemplateEngine engine =
templateEngineManager.getTemplateEngine(resolved, templateSuffix);
+ if (engine == null) {
+ return false;
+ }
+ for (Template candidate : resolved.getPossibleTemplates(engine)) {
+ if (CONSTRAINT_THEME.equals(candidate.getTheme())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* The object server-side validation runs against, which is what the
derived {@code data-msg-*}
* messages must be resolved against too: {@code
ValidatorSupport.getMessage} builds a
diff --git
a/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
b/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
index 2a1f9b4f3..606d163da 100644
--- a/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
+++ b/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
@@ -26,6 +26,8 @@ public class ConstraintAction extends ActionSupport {
private String username;
private String comment;
private String bio;
+ private String nickname;
+ private ConstraintUser user;
public String getUsername() {
return username;
@@ -53,4 +55,22 @@ public class ConstraintAction extends ActionSupport {
public void setBio(String bio) {
this.bio = bio;
}
+
+ public String getNickname() {
+ return nickname;
+ }
+
+ @StrutsParameter
+ public void setNickname(String nickname) {
+ this.nickname = nickname;
+ }
+
+ @StrutsParameter(depth = 1)
+ public ConstraintUser getUser() {
+ return user;
+ }
+
+ public void setUser(ConstraintUser user) {
+ this.user = user;
+ }
}
diff --git
a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
index 7a76f1d7b..6d70cd20e 100644
---
a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
+++
b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
@@ -34,6 +34,8 @@ import java.util.Map;
public class ConstraintAttributesTest extends AbstractUITagTest {
private FormTag form;
+ private String theme = "html5";
+ private String fieldName = "username";
public void testNoConstraintsWhenTheConstantIsOff() throws Exception {
initDispatcherWith("false");
@@ -49,6 +51,41 @@ public class ConstraintAttributesTest extends
AbstractUITagTest {
assertEquals("3", constraints.get("minlength"));
}
+ /**
+ * Only the html5 theme renders the derived map, so under any other theme
the derivation is
+ * wasted work per field — validator resolution plus a {@code
getMessage()} call each.
+ */
+ public void testNoConstraintsUnderAThemeThatDoesNotRenderThem() throws
Exception {
+ initDispatcherWith("true");
+ theme = "xhtml";
+
+ assertNull(renderFieldAndReturnConstraints(null));
+ }
+
+ /**
+ * A custom theme declares {@code parent = html5} in its theme.properties
and inherits the
+ * templates that render the map, so the gate has to walk the ancestry,
not match the name.
+ */
+ public void testConstraintsUnderAThemeInheritingFromHtml5() throws
Exception {
+ initDispatcherWith("true");
+ theme = "html5child";
+
+ Map<String, String> constraints =
renderFieldAndReturnConstraints(null);
+ assertNotNull("expected constraints under a child of html5",
constraints);
+ assertEquals("3", constraints.get("minlength"));
+ }
+
+ public void testVisitorValidatedNestedFieldGetsTheConcreteConstraint()
throws Exception {
+ initDispatcherWith("true");
+ fieldName = "user.name";
+
+ Map<String, String> constraints =
renderFieldAndReturnConstraints(null);
+ assertNotNull("expected constraints for a visitor-validated field",
constraints);
+ assertEquals("required", constraints.get("required"));
+ assertEquals("name is required",
constraints.get("data-msg-requiredstring"));
+ assertFalse(constraints.containsKey("data-msg-field-visitor"));
+ }
+
/**
* Pins the hook to running after {@code evaluateExtraParams()}. A {@code
stringlength} validator on
* a control the browser treats as numeric must not emit {@code minlength}
at all — that attribute
@@ -139,11 +176,12 @@ public class ConstraintAttributesTest extends
AbstractUITagTest {
form.setPageContext(pageContext);
form.setAction("constraintAction");
form.setNamespace("");
+ form.setTheme(theme);
form.doStartTag();
TextFieldTag field = new TextFieldTag();
field.setPageContext(pageContext);
- field.setName("username");
+ field.setName(fieldName);
if (type != null) {
field.setType(type);
}
diff --git
a/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
b/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
similarity index 54%
copy from core/src/test/java/org/apache/struts2/components/ConstraintAction.java
copy to core/src/test/java/org/apache/struts2/components/ConstraintUser.java
index 2a1f9b4f3..0acfc461b 100644
--- a/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
+++ b/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
@@ -18,39 +18,15 @@
*/
package org.apache.struts2.components;
-import org.apache.struts2.ActionSupport;
-import org.apache.struts2.interceptor.parameter.StrutsParameter;
+public class ConstraintUser {
-public class ConstraintAction extends ActionSupport {
+ private String name;
- private String username;
- private String comment;
- private String bio;
-
- public String getUsername() {
- return username;
- }
-
- @StrutsParameter
- public void setUsername(String username) {
- this.username = username;
- }
-
- public String getComment() {
- return comment;
- }
-
- @StrutsParameter
- public void setComment(String comment) {
- this.comment = comment;
- }
-
- public String getBio() {
- return bio;
+ public String getName() {
+ return name;
}
- @StrutsParameter
- public void setBio(String bio) {
- this.bio = bio;
+ public void setName(String name) {
+ this.name = name;
}
}
diff --git
a/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java
b/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java
index c43cfe6b0..9c819b059 100644
--- a/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java
+++ b/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java
@@ -64,11 +64,17 @@ public class ControlTypeTest extends AbstractUITagTest {
assertEquals(HtmlControlType.FILE, file.getControlType());
}
- public void testControlsWithoutAnOverrideAreUnknown() {
- // CheckboxInterceptor substitutes "false" for an unticked box, so the
server accepts what
- // a browser "required" would block — that is a real false reject, and
the reason Checkbox
- // and Hidden deliberately have no getControlType() override.
- assertEquals(HtmlControlType.OTHER, new Checkbox(stack, request,
response).getControlType());
- assertEquals(HtmlControlType.OTHER, new Hidden(stack, request,
response).getControlType());
+ /**
+ * Neither type supports a constraint and the default provider never emits
{@code required} for
+ * them (CheckboxInterceptor substitutes "false" for an unticked box, so
the server accepts what
+ * the browser would block). The honest type is for replacement providers,
which otherwise cannot
+ * tell a checkbox or hidden input from an unknown control.
+ */
+ public void testCheckboxIsCheckbox() {
+ assertEquals(HtmlControlType.CHECKBOX, new Checkbox(stack, request,
response).getControlType());
+ }
+
+ public void testHiddenIsHidden() {
+ assertEquals(HtmlControlType.HIDDEN, new Hidden(stack, request,
response).getControlType());
}
}
diff --git
a/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
b/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
index cdc2fbfc2..0b09f608e 100644
---
a/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
+++
b/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
@@ -22,6 +22,7 @@ import org.apache.struts2.TestConfigurationProvider;
import org.apache.struts2.mock.MockActionProxy;
import org.apache.struts2.validator.ActionValidatorManager;
import org.apache.struts2.validator.Validator;
+import org.apache.struts2.validator.validators.RequiredStringValidator;
import org.apache.struts2.views.jsp.AbstractUITagTest;
import org.apache.struts2.views.jsp.ui.FormTag;
@@ -31,8 +32,11 @@ import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.nullable;
+import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.when;
@@ -64,16 +68,79 @@ public class FormFieldValidatorsTest extends
AbstractUITagTest {
form.getFieldValidators("myUpDownSelectTag");
form.getFieldValidators("someOtherField");
- // fully qualified: AbstractUITagTest inherits verify(URL), which
would shadow a static import
- org.mockito.Mockito.verify(manager, times(1))
- .getValidators(any(Class.class), anyString(),
nullable(String.class));
+ then(manager).should(times(1)).getValidators(any(Class.class),
anyString(), nullable(String.class));
+ }
+
+ /**
+ * The common {@code <s:form>} carries no {@code action} attribute; {@code
ServletUrlRenderer}
+ * then resolves the name from the current invocation into {@code
attributes.actionName} only.
+ * Validators scoped to that alias ({@code
ConstraintAction-constraintAction-validation.xml})
+ * must still be found, not silently skipped under an empty context.
+ */
+ public void
testFindsAliasScopedValidatorsForAFormWithoutAnActionAttribute() throws
Exception {
+ currentActionIs("constraintAction");
+ FormTag tag = new FormTag();
+ tag.setPageContext(pageContext);
+ tag.doStartTag();
+ Form form = (Form) tag.getComponent();
+
+ List<Validator> validators = form.getFieldValidators("nickname");
+
+ assertEquals(1, validators.size());
+ assertEquals("requiredstring", validators.get(0).getValidatorType());
+ }
+
+ /**
+ * A {@code visitor} on {@code user} reaches {@code user.name} through
{@code
+ * FieldVisitorValidatorWrapper}, which exists to prefix the field name
for the deprecated JS
+ * validator and implements only {@code FieldValidator}. The constraint
provider dispatches on the
+ * concrete validator type, so the wrapper has to be unwrapped or the
field gets a
+ * {@code data-msg-field-visitor} and never a constraint.
+ */
+ public void testUnwrapsVisitorValidatedFieldValidators() throws Exception {
+ currentActionIs("constraintAction");
+ Form form = formFor("constraintAction");
+
+ List<Validator> validators = form.getFieldValidators("user.name");
+
+ assertEquals(1, validators.size());
+ assertTrue("expected the concrete validator, got " +
validators.get(0).getClass(),
+ validators.get(0) instanceof RequiredStringValidator);
+ }
+
+ /**
+ * The manager caches only validator configs and builds fresh instances on
every call, so the
+ * visitor branch must be resolved once per form like the top-level list,
not once per field.
+ */
+ public void testResolvesAVisitorsValidatorsOnlyOnceAcrossFields() throws
Exception {
+ currentActionIs("constraintAction");
+ Form form = formFor("constraintAction");
+ ActionValidatorManager manager =
spy(container.getInstance(ActionValidatorManager.class));
+ form.setActionValidatorManager(manager);
+
+ form.getFieldValidators("user.name");
+ form.getFieldValidators("username");
+ form.getFieldValidators("bio");
+
+ then(manager).should(times(1)).getValidators(eq(ConstraintUser.class),
anyString());
+ }
+
+ private void currentActionIs(String actionName) {
+ MockActionProxy proxy = (MockActionProxy) actionProxy;
+ proxy.setActionName(actionName);
+ proxy.setNamespace("");
+
proxy.setConfig(configuration.getRuntimeConfiguration().getActionConfig("",
actionName));
}
private Form formForDoubleValidationAction() throws Exception {
+ return formFor("doubleValidationAction");
+ }
+
+ private Form formFor(String actionName) throws Exception {
FormTag tag = new FormTag();
tag.setPageContext(pageContext);
tag.setName("myForm");
- tag.setAction("doubleValidationAction");
+ tag.setAction(actionName);
tag.setNamespace("");
tag.doStartTag();
return (Form) tag.getComponent();
diff --git
a/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
b/core/src/test/resources/org/apache/struts2/components/ConstraintAction-constraintAction-validation.xml
similarity index 62%
copy from
core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
copy to
core/src/test/resources/org/apache/struts2/components/ConstraintAction-constraintAction-validation.xml
index 30bf5dd43..c6d7c5870 100644
---
a/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
+++
b/core/src/test/resources/org/apache/struts2/components/ConstraintAction-constraintAction-validation.xml
@@ -21,23 +21,9 @@
-->
<!DOCTYPE validators PUBLIC "-//Apache Struts//XWork Validator 1.0//EN"
"https://struts.apache.org/dtds/xwork-validator-1.0.dtd">
<validators>
- <field name="username">
- <field-validator type="stringlength">
- <param name="trim">false</param>
- <param name="minLength">3</param>
- <message>username must be at least ${minLength}
characters</message>
- </field-validator>
- </field>
- <field name="comment">
+ <field name="nickname">
<field-validator type="requiredstring">
- <message>Contains "quotes" and <brackets></message>
- </field-validator>
- </field>
- <field name="bio">
- <field-validator type="stringlength">
- <param name="trim">false</param>
- <param name="maxLength">10</param>
- <message>bio must be at most ${maxLength} characters</message>
+ <message>nickname is required</message>
</field-validator>
</field>
</validators>
diff --git
a/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
b/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
index 30bf5dd43..cadbb050a 100644
---
a/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
+++
b/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
@@ -40,4 +40,9 @@
<message>bio must be at most ${maxLength} characters</message>
</field-validator>
</field>
+ <field name="user">
+ <field-validator type="visitor">
+ <message/>
+ </field-validator>
+ </field>
</validators>
diff --git
a/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
b/core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
similarity index 62%
copy from
core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
copy to
core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
index 30bf5dd43..b9611d705 100644
---
a/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
+++
b/core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
@@ -21,23 +21,9 @@
-->
<!DOCTYPE validators PUBLIC "-//Apache Struts//XWork Validator 1.0//EN"
"https://struts.apache.org/dtds/xwork-validator-1.0.dtd">
<validators>
- <field name="username">
- <field-validator type="stringlength">
- <param name="trim">false</param>
- <param name="minLength">3</param>
- <message>username must be at least ${minLength}
characters</message>
- </field-validator>
- </field>
- <field name="comment">
+ <field name="name">
<field-validator type="requiredstring">
- <message>Contains "quotes" and <brackets></message>
- </field-validator>
- </field>
- <field name="bio">
- <field-validator type="stringlength">
- <param name="trim">false</param>
- <param name="maxLength">10</param>
- <message>bio must be at most ${maxLength} characters</message>
+ <message>name is required</message>
</field-validator>
</field>
</validators>
diff --git a/core/src/test/resources/template/html5child/theme.properties
b/core/src/test/resources/template/html5child/theme.properties
new file mode 100644
index 000000000..e79317a26
--- /dev/null
+++ b/core/src/test/resources/template/html5child/theme.properties
@@ -0,0 +1,19 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+parent = html5